From 8781d9f38e901eabda60016a9e01cf7435285ebf Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:48:22 +0200 Subject: [PATCH 001/232] feat(arcade): pin @arcadeai/arcadejs at 2.4.1 Exact pin (no ^/~), lockfile resolves 2.4.1. No code. SDK shapes read from node_modules/@arcadeai/arcadejs type declarations, as later tasks depend on: APIError (core/error.d.ts, re-exported from error.d.ts) class APIError extends ArcadeError constructor(status, error, message, headers) // 4 args OWN fields: readonly status, readonly headers, readonly error Nothing names the tool; no toolName. The tool name is only in the request, not on the error. static generate(status, errorResponse, message, headers). Subclasses: BadRequestError 400, AuthenticationError 401, PermissionDeniedError 403, NotFoundError 404, ConflictError 409, UnprocessableEntityError 422, RateLimitError 429, InternalServerError, APIConnectionError/APIUserAbortError (status undefined). ToolAuthorizeParams { tool_name: string; next_uri?: string; tool_version?: string; user_id?: string } // next_uri IS declared AuthorizationResponse (shared.d.ts) { id?: string; context?: { token?, user_info? }; provider_id?: string; scopes?: string[]; status?: 'not_started'|'pending'|'completed'|'failed'; url?: string; user_id?: string } admin.userConnections list row = UserConnectionResponse { id?, connection_id?, connection_status?, provider_description?, provider_id?, provider_type?, provider_user_info?: unknown, scopes?: string[], user_id? } // carries BOTH id and connection_id list params: provider_id?, user_id? plus offset page params; delete(id: string). Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 3 +++ server/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/bun.lock b/bun.lock index ed19ae670..7b2f6c6a1 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "@ag-ui/client": "1.0.1", "@ag-ui/core": "1.0.1", "@ag-ui/mastra": "1.1.6", + "@arcadeai/arcadejs": "2.4.1", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/electron": "1.7.1", "@better-auth/expo": "1.7.1", @@ -170,6 +171,8 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@arcadeai/arcadejs": ["@arcadeai/arcadejs@2.4.1", "", { "bin": { "arcadeai-arcadejs": "bin/cli" } }, "sha512-DLpvvDBvRNjzVmFYJ+upPrUJ8YRBoGWqPfzhuFp2+lh5Shxm0l9qLwe1Ah4pKvuCatU7TLOOTkFpICitTuuJeQ=="], + "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], diff --git a/server/package.json b/server/package.json index 0d5ba1cdf..1e1374164 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@ag-ui/client": "1.0.1", "@ag-ui/core": "1.0.1", "@ag-ui/mastra": "1.1.6", + "@arcadeai/arcadejs": "2.4.1", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/electron": "1.7.1", "@better-auth/expo": "1.7.1", From 78014b1c366b8fe9a8fe58d887ef0f3009088616 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:51:21 +0200 Subject: [PATCH 002/232] feat(arcade): add hard-coded brokered vendor name guard test (expected RED) Reads every .ts in server/src/plugins except access.ts (the vocabulary home) and fails on provenance/provider comparisons against "composio" or "arcade". Committed failing on purpose: it is the checklist for micro-tasks 2.3, 2.4, 2.5 and 3.2. Offenders, run against tree 8781d9f: shared-accounts.ts:42 store.ts:1439 store.ts:1497 store.ts:1503 store.ts:2613 store.ts:3445 store.ts:3715 store.ts:3764 store.ts:4357 store.ts:4540 store.ts:5435 store.ts:5777 Known gap: eq(mcpServers.provenance, "composio") (2 sites in logos.ts) is not matched; micro-task 2.5 adds that spelling. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/brokered-provenance.test.ts | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 server/tests/brokered-provenance.test.ts diff --git a/server/tests/brokered-provenance.test.ts b/server/tests/brokered-provenance.test.ts new file mode 100644 index 000000000..068cb4727 --- /dev/null +++ b/server/tests/brokered-provenance.test.ts @@ -0,0 +1,42 @@ +// server/tests/brokered-provenance.test.ts +/** + * Guard: no plugin module outside `access.ts` may hard-code a brokered vendor + * name ("composio" / "arcade") in a provenance or provider comparison. + * + * `access.ts` is the one exempt file because it is where the vocabulary lives. + * + * KNOWN GAP: the regexes below do NOT catch + * `eq(mcpServers.provenance, "composio")` (two sites in `logos.ts`). + * Micro-task 2.5 will add that spelling. A guard that catches one spelling of + * a mistake and not the other is worse than none, because it is read as + * coverage. + * + * EXPECTED TO FAIL until micro-tasks 2.3, 2.5 and 3.2 clear the offenders. + */ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const PLUGINS_DIR = join(import.meta.dir, "../src/plugins"); + +const PATTERNS: RegExp[] = [ + /provenance\s*[!=]==\s*"(composio|arcade)"/, + /brokeredConnections\.provider\s*,\s*"(composio|arcade)"/, + /provider:\s*"(composio|arcade)"/, +]; + +test("no plugin module hard-codes a brokered vendor name outside access.ts", () => { + const offenders: string[] = []; + const files = readdirSync(PLUGINS_DIR) + .filter((f) => f.endsWith(".ts") && f !== "access.ts") + .sort(); + for (const file of files) { + const lines = readFileSync(join(PLUGINS_DIR, file), "utf8").split("\n"); + lines.forEach((line, i) => { + if (PATTERNS.some((re) => re.test(line))) { + offenders.push(`${file}:${i + 1}`); + } + }); + } + expect(offenders).toEqual([]); +}); From b8942127faf49664d769e64707acfef6cab6ef9c Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:51:25 +0200 Subject: [PATCH 003/232] feat(arcade): land recorded Arcade catalogue fixture Recorded real catalogue response from OpenTag feat/arcade-provider. Counts: 4 toolkits upstream (curated: Apollo, Asana, Attio; generated *Api: AirtableApi). 3 actions carry metadata.behavior, 1 carries none. 3 actions have non-empty oauth2.scopes. Upstream had NO action with oauth2: {}, so APPENDED one entry (tools.unscoped, AirtableApi.ListBases, cloned from the unclassified entry with oauth2 emptied). Nothing existing was reshaped. Now 5 actions: 3 curated, 2 generated; 3 scoped, 1 oauth2: {}. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/fixtures/arcade-catalogue.json | 458 ++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 server/tests/fixtures/arcade-catalogue.json diff --git a/server/tests/fixtures/arcade-catalogue.json b/server/tests/fixtures/arcade-catalogue.json new file mode 100644 index 000000000..8b920da5a --- /dev/null +++ b/server/tests/fixtures/arcade-catalogue.json @@ -0,0 +1,458 @@ +{ + "note": "Captured from the live Arcade catalogue on 2026-09-15, read-only. Shapes are verbatim; identifiers that could be project-specific are redacted. Regenerate rather than hand-edit.", + "tools": { + "read": { + "fully_qualified_name": "Apollo.EnrichOrganization@0.1.0", + "qualified_name": "Apollo.EnrichOrganization", + "name": "EnrichOrganization", + "description": "Turn a company domain into firmographics (industry, size, revenue, funding,\nlocation) so a rep can qualify and size an account. Consumes one enrichment\ncredit on a match; when the plan is out of credits the result reports\nstatus=insufficient_credits rather than failing.", + "toolkit": { + "name": "Apollo", + "description": "Arcade tools designed for LLMs to interact with Apollo.io sales intelligence", + "version": "0.1.0" + }, + "input": { + "parameters": [ + { + "name": "domain", + "required": true, + "description": "Company domain to look up, such as apollo.io. A full URL or www prefix is accepted and normalized.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + } + ] + }, + "output": { + "available_modes": [ + "value", + "error" + ], + "description": "The matched company, or found=False when no match.", + "value_schema": { + "val_type": "json", + "properties": { + "status": { + "val_type": "string", + "enum": [ + "ok", + "insufficient_credits" + ] + }, + "found": { + "val_type": "boolean" + }, + "id": { + "val_type": "string" + }, + "name": { + "val_type": "string" + }, + "domain": { + "val_type": "string" + }, + "industry": { + "val_type": "string" + }, + "estimated_num_employees": { + "val_type": "integer" + }, + "annual_revenue": { + "val_type": "integer", + "nullable": true + }, + "total_funding": { + "val_type": "integer", + "nullable": true + }, + "founded_year": { + "val_type": "integer", + "nullable": true + }, + "location": { + "val_type": "string" + }, + "phone": { + "val_type": "string", + "nullable": true + } + }, + "required_keys": [ + "annual_revenue", + "domain", + "estimated_num_employees", + "found", + "founded_year", + "id", + "industry", + "location", + "name", + "phone", + "status", + "total_funding" + ] + } + }, + "requirements": { + "met": false, + "secrets": [ + { + "key": "APOLLO_API_KEY", + "met": false, + "status_reason": "Secret APOLLO_API_KEY not found" + } + ] + }, + "metadata": { + "classification": { + "service_domains": [ + "crm" + ] + }, + "behavior": { + "operations": [ + "read" + ], + "read_only": true, + "destructive": false, + "idempotent": true, + "open_world": true + } + } + }, + "write": { + "fully_qualified_name": "Asana.AttachFileToTask@1.2.2", + "qualified_name": "Asana.AttachFileToTask", + "name": "AttachFileToTask", + "description": "Attaches a file to an Asana task\n\nProvide exactly one of file_content_str, file_content_base64, or file_content_url, never more\nthan one.\n\n- Use file_content_str for text files (will be encoded using file_encoding)\n- Use file_content_base64 for binary files like images, PDFs, etc.\n- Use file_content_url if the file is hosted on an external URL", + "toolkit": { + "name": "Asana", + "description": "Arcade tools designed for LLMs to interact with Asana", + "version": "1.2.2" + }, + "input": { + "parameters": [ + { + "name": "task_id", + "required": true, + "description": "The ID of the task to attach the file to.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "file_name", + "required": true, + "description": "The name of the file to attach with format extension. E.g. 'Image.png' or 'Report.pdf'.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "file_content_str", + "required": false, + "description": "The string contents of the file to attach. Use this if the file is a text file. Defaults to None.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "file_content_base64", + "required": false, + "description": "The base64-encoded binary contents of the file. Use this for binary files like images or PDFs. Defaults to None.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "file_content_url", + "required": false, + "description": "The URL of the file to attach. Use this if the file is hosted on an external URL. Defaults to None.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "file_encoding", + "required": false, + "description": "The encoding of the file to attach. Only used with file_content_str. Defaults to 'utf-8'.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + } + ] + }, + "output": { + "available_modes": [ + "value", + "error" + ], + "description": "The task with the file attached.", + "value_schema": { + "val_type": "json" + } + }, + "requirements": { + "met": true, + "authorization": { + "id": "arcade-asana", + "provider_id": "asana", + "provider_type": "oauth2", + "oauth2": { + "scopes": [ + "default" + ] + }, + "status": "active" + } + }, + "metadata": { + "classification": { + "service_domains": [ + "project_management" + ] + }, + "behavior": { + "operations": [ + "create" + ], + "read_only": false, + "destructive": false, + "idempotent": false, + "open_world": true + } + } + }, + "destructive": { + "fully_qualified_name": "Attio.RemoveFromList@1.1.3", + "qualified_name": "Attio.RemoveFromList", + "name": "RemoveFromList", + "description": "Remove a record from an Attio list.\n\nNote: Use the entry_id, not the record_id. Get entry_id from get_list_entries.", + "toolkit": { + "name": "Attio", + "description": "Arcade tools designed for LLMs to interact with Attio CRM", + "version": "1.1.3" + }, + "input": { + "parameters": [ + { + "name": "list_id", + "required": true, + "description": "List UUID", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "entry_id", + "required": true, + "description": "Entry UUID (not record ID)", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + } + ] + }, + "output": { + "available_modes": [ + "value", + "error" + ], + "description": "Removal confirmation", + "value_schema": { + "val_type": "json" + } + }, + "requirements": { + "met": true, + "authorization": { + "id": "arcade-attio", + "provider_id": "attio", + "provider_type": "oauth2", + "oauth2": { + "scopes": [ + "call_recording:read", + "list_configuration:read-write", + "list_entry:read-write", + "meeting:read", + "note:read-write", + "object_configuration:read-write", + "record_permission:read-write", + "task:read-write", + "user_management:read" + ] + }, + "status": "active" + } + }, + "metadata": { + "classification": { + "service_domains": [ + "crm" + ] + }, + "behavior": { + "operations": [ + "delete" + ], + "read_only": false, + "destructive": true, + "idempotent": true, + "open_world": true + } + } + }, + "unclassified": { + "fully_qualified_name": "AirtableApi.AddBaseCollaborator@0.4.1", + "qualified_name": "AirtableApi.AddBaseCollaborator", + "name": "AddBaseCollaborator", + "description": "Add a collaborator to an Airtable base.\n\n Use this tool to add a new collaborator to a specified Airtable base. It facilitates inviting one collaborator at a time.\n\n Note: Understanding the request schema is necessary to properly create\n the stringified JSON input object for execution.\n\nThis operation also requires path parameters.\n\n Modes:\n - GET_REQUEST_SCHEMA: Returns the schema. Only call if you don't\n already have it. Do NOT call repeatedly if you already received\n the schema.\n - EXECUTE: Performs the operation with the provided request body\n JSON.\n Note: You must also provide the required path parameters when executing.\n\n If you need the schema, call with mode='get_request_schema' ONCE, then execute.\n ", + "toolkit": { + "name": "AirtableApi", + "description": "Tools that enable LLMs to interact directly with the airtable API.", + "version": "0.4.1" + }, + "input": { + "parameters": [ + { + "name": "mode", + "required": true, + "description": "Operation mode: 'get_request_schema' returns the OpenAPI spec for the request body, 'execute' performs the actual operation", + "value_schema": { + "val_type": "string", + "enum": [ + "get_request_schema", + "execute" + ] + }, + "inferrable": true + }, + { + "name": "base_id", + "required": false, + "description": "The ID of the Airtable base to which the collaborator will be added. Required when mode is 'execute', ignored when mode is 'get_request_schema'.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "request_body", + "required": false, + "description": "Stringified JSON representing the request body. Required when mode is 'execute', ignored when mode is 'get_request_schema'", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + } + ] + }, + "output": { + "available_modes": [ + "value", + "error" + ], + "description": "Response from the API endpoint 'add-base-collaborator'.", + "value_schema": { + "val_type": "json" + } + }, + "requirements": { + "met": false, + "authorization": { + "id": "arcade-airtable", + "provider_type": "oauth2", + "oauth2": { + "scopes": [ + "workspacesAndBases:write" + ] + }, + "status": "inactive", + "status_reason": "Provider not found" + } + } + }, + "unscoped": { + "fully_qualified_name": "AirtableApi.ListBases@0.4.1", + "qualified_name": "AirtableApi.ListBases", + "name": "ListBases", + "description": "Appended to the recorded response: a generated action whose oauth2 requirement declares no scopes.", + "toolkit": { + "name": "AirtableApi", + "description": "Tools that enable LLMs to interact directly with the airtable API.", + "version": "0.4.1" + }, + "input": { + "parameters": [ + { + "name": "mode", + "required": true, + "description": "Operation mode: 'get_request_schema' returns the OpenAPI spec for the request body, 'execute' performs the actual operation", + "value_schema": { + "val_type": "string", + "enum": [ + "get_request_schema", + "execute" + ] + }, + "inferrable": true + }, + { + "name": "base_id", + "required": false, + "description": "The ID of the Airtable base to which the collaborator will be added. Required when mode is 'execute', ignored when mode is 'get_request_schema'.", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + }, + { + "name": "request_body", + "required": false, + "description": "Stringified JSON representing the request body. Required when mode is 'execute', ignored when mode is 'get_request_schema'", + "value_schema": { + "val_type": "string" + }, + "inferrable": true + } + ] + }, + "output": { + "available_modes": [ + "value", + "error" + ], + "description": "Response from the API endpoint 'add-base-collaborator'.", + "value_schema": { + "val_type": "json" + } + }, + "requirements": { + "met": false, + "authorization": { + "id": "arcade-airtable", + "provider_type": "oauth2", + "oauth2": {}, + "status": "inactive", + "status_reason": "Provider not found" + } + } + } + }, + "page_envelope": { + "limit": 2, + "offset": 0, + "page_count": 2, + "total_count": 8258, + "items": [] + } +} From c79bdeac70441ca20c527f94da5e3493c603469e Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:51:26 +0200 Subject: [PATCH 004/232] feat(arcade): add case-preserving toolkitOf and listNeedsCredential Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade.ts | 34 ++++++++++++++++++++++++++ server/tests/arcade-transport.test.ts | 35 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 server/src/plugins/arcade.ts create mode 100644 server/tests/arcade-transport.test.ts diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts new file mode 100644 index 000000000..6367d9da6 --- /dev/null +++ b/server/src/plugins/arcade.ts @@ -0,0 +1,34 @@ +/** + * The Arcade transport's vendor-free half. Imports no vendor package and nothing from + * `arcade-adapter.ts`: the adapter imports this module, never the reverse (see `./broker`, which + * records that the other direction is a cycle). + */ + +/** + * Whether listing tools needs somebody's credential. It does not: the listing uses the deployment's + * own Arcade key, and a listing is not a person's. + */ +export const listNeedsCredential = false; + +/** + * Which toolkit this connection is about, from `arcade://Toolkit`. + * + * CASE IS PRESERVED AND NOTHING IS NORMALIZED. Arcade's toolkit and action names are case-sensitive + * and dotted, where Composio's slugs are neither, so a lowercased toolkit reaches nothing: + * `arcade://Gmail` and `arcade://gmail` are distinct apps. + * + * ONE TOOLKIT OR NOTHING. This answer is the name the brokered gate looks a person's row up by, so a + * url read loosely is one app's connection satisfying a call against another. + * `arcade://Gmail/messages` must not answer `"Gmail/messages"`: a path segment is not an app. + * + * TRIMMED BEFORE THE SLASHES COME OFF, as `composio.ts` does: the other order leaves a space as the + * last character, so the slash is not at the end and nothing matches. + */ +const TOOLKIT_NAME = /^[A-Za-z0-9_-]+$/; + +export function toolkitOf(url: string): string | null { + const prefix = "arcade://"; + if (!url.startsWith(prefix)) return null; + const name = url.slice(prefix.length).trim().replace(/\/+$/, ""); + return TOOLKIT_NAME.test(name) ? name : null; +} diff --git a/server/tests/arcade-transport.test.ts b/server/tests/arcade-transport.test.ts new file mode 100644 index 000000000..d4a43ec3f --- /dev/null +++ b/server/tests/arcade-transport.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { listNeedsCredential, toolkitOf } from "../src/plugins/arcade"; + +/** The Arcade transport's boundary, asserted with no network and no database. */ +describe("toolkitOf", () => { + test("case is preserved: Gmail and gmail are distinct apps", () => { + expect(toolkitOf("arcade://Gmail")).toBe("Gmail"); + expect(toolkitOf("arcade://gmail")).toBe("gmail"); + expect(toolkitOf("arcade://Gmail")).not.toBe(toolkitOf("arcade://gmail")); + }); + + test("a url that is not arcade:// answers null", () => { + expect(toolkitOf("composio://gmail")).toBeNull(); + expect(toolkitOf("https://arcade.dev")).toBeNull(); + expect(toolkitOf("")).toBeNull(); + }); + + test("padding and trailing slashes answer the bare toolkit", () => { + expect(toolkitOf("arcade://Gmail/")).toBe("Gmail"); + expect(toolkitOf("arcade://Gmail//")).toBe("Gmail"); + expect(toolkitOf("arcade://Gmail/ ")).toBe("Gmail"); + expect(toolkitOf("arcade:// Gmail ")).toBe("Gmail"); + }); + + test("a path segment is not an app", () => { + expect(toolkitOf("arcade://Gmail/messages")).toBeNull(); + expect(toolkitOf("arcade://")).toBeNull(); + }); +}); + +describe("listNeedsCredential", () => { + test("a listing is the deployment's, not somebody's", () => { + expect(listNeedsCredential).toBe(false); + }); +}); From a9755d049f47d662079e7457400bd3fd80681baf Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:51:28 +0200 Subject: [PATCH 005/232] feat(arcade): list Arcade rows on the connected-accounts index Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/connected-accounts/index.tsx | 11 +++- app/tests/arcade-index.test.tsx | 63 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 app/tests/arcade-index.test.tsx diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index dd7736c40..c1245a879 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -68,6 +68,13 @@ const MARKS: Record> = { const markFor = (key: string) => MARKS[key] ?? IconPlug; +/** + * Whether a row came through a broker. A deployment only ever holds rows from one provider, so the + * page needs to recognise "brokered", not to know which broker. + */ +const isBrokered = (server: PluginServer): boolean => + server.provenance === "composio" || server.provenance === "arcade"; + /** * The brokered apps this page lists, which is not every brokered row. * @@ -80,7 +87,7 @@ export function brokeredAccountsListedOn( ): PluginServer[] { return servers.filter( (server) => - server.provenance === "composio" && + isBrokered(server) && server.authScheme !== "NO_AUTH" && server.accountMode !== "shared", ); @@ -92,7 +99,7 @@ export function sharedAccountsListedOn( ): PluginServer[] { return servers.filter( (server) => - server.provenance === "composio" && server.accountMode === "shared", + isBrokered(server) && server.accountMode === "shared", ); } diff --git a/app/tests/arcade-index.test.tsx b/app/tests/arcade-index.test.tsx new file mode 100644 index 000000000..d64cd7086 --- /dev/null +++ b/app/tests/arcade-index.test.tsx @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import type { PluginServer } from "@/lib/plugins/queries"; +import { + brokeredAccountsListedOn, + sharedAccountsListedOn, +} from "@/routes/_authed/settings/connected-accounts/index"; + +/** + * The Connected accounts index lists brokered rows from either broker. A deployment holds rows from + * only one provider, so the browser needs no provider switch: it only needs to recognise "brokered". + */ + +function server(overrides: Partial & { id: string }) { + return { + title: "App", + summary: "", + vendor: "Arcade", + url: `arcade://${overrides.id}`, + provenance: "arcade", + authScheme: "OAUTH2", + tools: [], + ...overrides, + } as PluginServer; +} + +const ids = (rows: PluginServer[]) => rows.map((row) => row.id); + +test("Arcade rows are listed by both functions under the same rules", () => { + const rows = [ + server({ id: "arcade-gmail" }), + server({ id: "arcade-noauth", authScheme: "NO_AUTH" }), + server({ id: "arcade-team", accountMode: "shared" }), + ]; + expect(ids(brokeredAccountsListedOn(rows))).toEqual(["arcade-gmail"]); + expect(ids(sharedAccountsListedOn(rows))).toEqual(["arcade-team"]); +}); + +test("Composio rows are listed exactly as before", () => { + const rows = [ + server({ id: "composio-gmail", provenance: "composio" }), + server({ + id: "composio-noauth", + provenance: "composio", + authScheme: "NO_AUTH", + }), + server({ + id: "composio-team", + provenance: "composio", + accountMode: "shared", + }), + ]; + expect(ids(brokeredAccountsListedOn(rows))).toEqual(["composio-gmail"]); + expect(ids(sharedAccountsListedOn(rows))).toEqual(["composio-team"]); +}); + +test("a row of neither provenance is excluded by both", () => { + const rows = [ + server({ id: "custom-a", provenance: "custom" }), + server({ id: "custom-b", provenance: "custom", accountMode: "shared" }), + ]; + expect(brokeredAccountsListedOn(rows)).toEqual([]); + expect(sharedAccountsListedOn(rows)).toEqual([]); +}); From 6edc16614ac7c540a0fbe0d03e12d9e0312025cd Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:51:37 +0200 Subject: [PATCH 006/232] feat(arcade): read ARCADE_API_KEY and OPENBOT_BROKER_PROVIDER in config Co-Authored-By: Claude Opus 5 (1M context) --- server/src/config.ts | 25 +++++++++++++++++++++++++ server/tests/config.test.ts | 16 ++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/server/src/config.ts b/server/src/config.ts index bb557c663..fde12336b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -217,6 +217,16 @@ export type DeploymentConfig = { * where a key that was revoked last week would have surfaced regardless. */ composioApiKey: string | undefined; + /** + * The Arcade API key, from `ARCADE_API_KEY`. Unset is the ordinary state. Trimmed and never + * validated here, for the same reason as `composioApiKey`. + */ + arcadeApiKey: string | undefined; + /** + * Which tool broker the operator asked for, from `OPENBOT_BROKER_PROVIDER`. Only read and + * validated here: which provider is active is decided elsewhere. Unset is `undefined`. + */ + brokerProvider: BrokerProvider | undefined; /** * Where this deployment is reached from outside, with no trailing slash. * @@ -384,6 +394,19 @@ function optional(environment: Environment, name: string): string | undefined { return environment[name]?.trim() || undefined; } +export type BrokerProvider = "composio" | "arcade"; + +function brokerProvider(environment: Environment): BrokerProvider | undefined { + const raw = optional(environment, "OPENBOT_BROKER_PROVIDER"); + if (raw === undefined) return undefined; + if (raw !== "composio" && raw !== "arcade") { + throw new Error( + `OPENBOT_BROKER_PROVIDER must be "composio" or "arcade", got "${raw}". Leave it unset to choose by which API key is configured.`, + ); + } + return raw; +} + /** * Whether this deployment says it is in production, which is what the two hard refusals turn on. * @@ -1281,6 +1304,8 @@ export function loadConfig( agentEndpointAllowedHosts: agentEndpointAllowedHosts(environment), deploymentId: optional(environment, "DEPLOYMENT_ID"), composioApiKey: optional(environment, "COMPOSIO_API_KEY"), + arcadeApiKey: optional(environment, "ARCADE_API_KEY"), + brokerProvider: brokerProvider(environment), publicUrl: ( optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl )?.replace(/\/+$/, ""), diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index d0398dd0b..ef28691aa 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -1132,3 +1132,19 @@ test("a Composio key is read when set and absent when not", () => { .composioApiKey, ).toBe("ak_example"); }); + +test("an Arcade key and a broker provider are read, and an unknown provider is refused", () => { + const unset = loadConfig(baseEnvironment); + expect(unset.arcadeApiKey).toBeUndefined(); + expect(unset.brokerProvider).toBeUndefined(); + const set = loadConfig({ + ...baseEnvironment, + ARCADE_API_KEY: " arc_example ", + OPENBOT_BROKER_PROVIDER: " arcade ", + }); + expect(set.arcadeApiKey).toBe("arc_example"); + expect(set.brokerProvider).toBe("arcade"); + expect(() => + loadConfig({ ...baseEnvironment, OPENBOT_BROKER_PROVIDER: "zapier" }), + ).toThrow(/OPENBOT_BROKER_PROVIDER must be "composio" or "arcade"/); +}); From c709b5e1c928d1771088b18dce9e4ece3d3e12ce Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:51:38 +0200 Subject: [PATCH 007/232] feat(arcade): admit arcade as a brokered_connections provider (migration 0053) Co-Authored-By: Claude Opus 5 (1M context) --- server/drizzle/0053_adorable_frank_castle.sql | 2 + server/drizzle/meta/0053_snapshot.json | 9493 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 4 +- server/tests/arcade-provider-check.test.ts | 54 + 5 files changed, 9558 insertions(+), 2 deletions(-) create mode 100644 server/drizzle/0053_adorable_frank_castle.sql create mode 100644 server/drizzle/meta/0053_snapshot.json create mode 100644 server/tests/arcade-provider-check.test.ts diff --git a/server/drizzle/0053_adorable_frank_castle.sql b/server/drizzle/0053_adorable_frank_castle.sql new file mode 100644 index 000000000..8910ff77f --- /dev/null +++ b/server/drizzle/0053_adorable_frank_castle.sql @@ -0,0 +1,2 @@ +ALTER TABLE "brokered_connections" DROP CONSTRAINT "brokered_connections_provider_check";--> statement-breakpoint +ALTER TABLE "brokered_connections" ADD CONSTRAINT "brokered_connections_provider_check" CHECK ("brokered_connections"."provider" IN ('composio', 'arcade')); \ No newline at end of file diff --git a/server/drizzle/meta/0053_snapshot.json b/server/drizzle/meta/0053_snapshot.json new file mode 100644 index 000000000..02f232eeb --- /dev/null +++ b/server/drizzle/meta/0053_snapshot.json @@ -0,0 +1,9493 @@ +{ + "id": "fd489e70-85c6-4230-ad16-14807dd2c1fe", + "prevId": "e7a6567d-808f-491c-b2cc-487aa287735c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "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": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "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": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_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": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_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": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 068d23ae8..a385394a8 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1791396757748, "tag": "0052_shared_brokered_accounts", "breakpoints": true + }, + { + "idx": 53, + "version": "7", + "when": 1791773482676, + "tag": "0053_adorable_frank_castle", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 7d2d7d2e0..c2fe62576 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -410,7 +410,7 @@ export const composioConnections = pgTable( export const brokeredConnections = pgTable( "brokered_connections", { - provider: text("provider").$type<"composio">().notNull(), + provider: text("provider").$type<"composio" | "arcade">().notNull(), app: text("app").notNull(), holder: text("holder").$type<"person" | "deployment">().notNull(), userId: text("user_id"), @@ -431,7 +431,7 @@ export const brokeredConnections = pgTable( ), check( "brokered_connections_provider_check", - sql`${table.provider} IN ('composio')`, + sql`${table.provider} IN ('composio', 'arcade')`, ), uniqueIndex("brokered_connections_person_idx") .on(table.provider, table.app, table.userId) diff --git a/server/tests/arcade-provider-check.test.ts b/server/tests/arcade-provider-check.test.ts new file mode 100644 index 000000000..acc630492 --- /dev/null +++ b/server/tests/arcade-provider-check.test.ts @@ -0,0 +1,54 @@ +// server/tests/arcade-provider-check.test.ts +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { brokeredConnections } from "../src/db/schema/plugins"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const app = `arcade-check-${randomUUID().slice(0, 8)}`; + +/** The constraint a failed insert names, read from the driver's cause rather than drizzle's wrapper. */ +const outcome = (run: () => PromiseLike) => + Promise.resolve() + .then(run) + .then( + () => "inserted", + (error: Error) => + String((error.cause as Error | undefined)?.message ?? error.message), + ); + +afterAll(async () => { + await database.execute( + sql`delete from brokered_connections where app = ${app}`, + ); +}); + +describe("brokered_connections.provider", () => { + test("an arcade row inserts", async () => { + expect( + await outcome(() => + database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder: "deployment", + vendorUserId: `openbot-deployment:arcade:${app}`, + }), + ), + ).toBe("inserted"); + }); + + test("an unknown provider is refused by the CHECK", async () => { + expect( + await outcome(() => + database.insert(brokeredConnections).values({ + provider: "nope" as "arcade", + app, + holder: "deployment", + vendorUserId: "v", + }), + ), + ).toMatch(/brokered_connections_provider_check/); + }); +}); From 38ef0f3e73c907716d838b040c0e6bdbf47bcbe1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:52:08 +0200 Subject: [PATCH 008/232] feat(arcade): declare grantIsLocal on the broker seam and an optional action on authorize Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/broker.ts | 28 +++++++++++++++ server/src/plugins/composio-adapter.ts | 3 ++ server/tests/composio-adapter.test.ts | 7 +++- server/tests/composio-broker-seam.test.ts | 42 +++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 8595a1080..e9c0dfdbc 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -338,6 +338,22 @@ export type AuthConfigOutcome = "created" | "standing" | "not-needed"; * dial. */ export type ConnectedAppBroker = { + /** + * Whether this provider's grant is held by THIS deployment, as opposed to by the vendor. + * + * A DECLARED PROPERTY, AND NEVER A `provider === "arcade"` COMPARISON. Every permission check + * that has to know where a grant lives reads this field off the broker it was handed; none of + * them asks which vendor it is talking to. `store.ts:1631` is why: a gate and its exemption + * keyed on different things there, and the exemption failed OPEN — a duplicate row let a call + * run for somebody who had connected nothing. A vendor-name comparison inside a permission check + * is that same shape, two answers to one question that nothing forces to agree, and adding a + * provider would mean finding every such comparison by hand. A property on the seam makes the + * answer one value per broker and makes a new broker declare it or fail to type-check. + * + * TRUE for Composio, whose connection rows and grants live in this deployment's own tables. That + * is the behaviour it always had, made explicit here rather than changed. + */ + grantIsLocal: boolean; /** Every app the catalogue offers, which is what an administrator chooses from. */ listApps(): Promise; /** @@ -426,6 +442,18 @@ export type ConnectedAppBroker = { * address through that before it hands it here. */ returnUrl: string; + /** + * The one action this consent is being begun for, for a vendor that grants per action. + * + * OPTIONAL, AND IGNORABLE. An adapter whose vendor grants per app, as Composio does, has no use + * for it and drops it without throwing: handing an action to a broker that cannot use it is + * not an error, and a caller need not know which kind of broker it holds to call this. + * + * `returnUrl` ABOVE STAYS REQUIRED ON THE SEAM EVEN SO. An adapter whose vendor answers with + * its own return leg may ignore it, but the seam does not relax for that: the next adapter + * that needs it must not find the field optional because the last one did not. + */ + action?: string; }): Promise<{ redirectUrl: string }>; /** Whether this account is currently attached to this app at the vendor. */ isConnected(request: { diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 12a2a42cd..fdfad1988 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -3005,6 +3005,9 @@ export function buildComposioClient( }; const broker: ComposioBroker = { + // Composio's grant is held by this deployment. Its existing behaviour, declared; see + // {@link ConnectedAppBroker.grantIsLocal} for why this is a property and not a comparison. + grantIsLocal: true, /** * The catalogue, from memory where this process asked for it less than ten minutes ago. * diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index e80987a9c..44849b49c 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -3264,8 +3264,13 @@ describe("what a vendor failure becomes on its way out of the seam", () => { // projection arrives in this set, and the table that does not cover it fails here rather than // being the one door nobody thought to probe. const seam = buildComposioClient(fakeVendor({})); + // Methods only: `grantIsLocal` is a declared property of the broker, not a door to probe. + const methodsOf = (part: object) => + Object.entries(part) + .filter(([, member]) => typeof member === "function") + .map(([name]) => name); expect( - [...Object.keys(seam.broker), ...Object.keys(seam.actions)].sort(), + [...methodsOf(seam.broker), ...methodsOf(seam.actions)].sort(), ).toEqual(SEAM_CASES.map((seamCase) => seamCase.method).sort()); }); diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index 0c8a72bdd..c4817b236 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -5,6 +5,7 @@ import { brokerReturnUrl, brokerSentence, } from "../src/plugins/broker"; +import { createComposioClient } from "../src/plugins/composio-adapter"; /** * The broker seam's three decisions, asserted with no network and no database. @@ -143,3 +144,44 @@ describe("brokerReturnUrl", () => { } }); }); + +/** + * `grantIsLocal` is a DECLARED PROPERTY of the broker, read off the object and never derived from + * a vendor's name. Composio holds the grant in this deployment, so it declares true — which is the + * behaviour it always had, made explicit. + */ +describe("grantIsLocal", () => { + test("Composio declares that its grant is held by this deployment", () => { + expect(createComposioClient("never-dialled").broker.grantIsLocal).toBe( + true, + ); + }); + + test("a broker handed an action it cannot use ignores it rather than throwing", async () => { + const { broker } = createComposioClient("never-dialled"); + const original = globalThis.fetch; + // No network: an empty config listing, so `authorize` stops at its own refusal for having no + // config to connect against. What is asserted is that `action` was not what stopped it. + globalThis.fetch = (async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + try { + const attempt = await broker + .authorize({ + account: { holder: "person", userId: "u", vendorUserId: "u" }, + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + action: "LINEAR_CREATE_ISSUE", + }) + .then( + () => "resolved", + (error: unknown) => (error instanceof Error ? error.message : ""), + ); + expect(attempt).not.toMatch(/action/i); + } finally { + globalThis.fetch = original; + } + }); +}); From 5fc481e8d5e77eb94f7778d9ea3d158c1ff17597 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:53:06 +0200 Subject: [PATCH 009/232] feat(arcade): guard that no browser code under app/src dials Arcade Text-scans app/src recursively (359 .ts/.tsx files) for the arcadejs import, arcade.dev hosts and Arcade API paths. arcade:// row urls are excluded. Green from the first run; verified RED against a planted probe file, since deleted. Co-Authored-By: Claude Opus 5 (1M context) --- app/tests/arcade-no-browser-calls.test.ts | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 app/tests/arcade-no-browser-calls.test.ts diff --git a/app/tests/arcade-no-browser-calls.test.ts b/app/tests/arcade-no-browser-calls.test.ts new file mode 100644 index 000000000..058394a27 --- /dev/null +++ b/app/tests/arcade-no-browser-calls.test.ts @@ -0,0 +1,66 @@ +// app/tests/arcade-no-browser-calls.test.ts +/** + * Guard: no browser code under `app/src` ever dials Arcade. + * + * WHY: the vendor key never leaves the server and the browser never talks to + * Arcade (spec D25). The grant-status route exists precisely so a browser asks + * OpenBot rather than the vendor. A direct dial from `app/` would either need + * the deployment's key in the bundle or would leak which identities have flows + * outstanding. + * + * Flags any line that imports `@arcadeai/arcadejs` (static, dynamic `import(`, + * or `require(`), names an `arcade.dev` host, or names an Arcade API path. + * + * NOT AN OFFENCE: an `arcade://` url string. That is a brokered row's url, as + * the server hands it to the browser, and is not a network target. It is + * excluded explicitly below (and does not match the host pattern anyway); do + * not widen the host pattern to bare "arcade" or this guard starts crying wolf + * and gets weakened. + * + * EXPECTED GREEN. Unlike brokered-provenance.test.ts (committed RED as a + * checklist), no browser Arcade code exists, so this passes from the start. + * If it goes red, remove the offending browser code; do not loosen the guard. + * The scan-count assertion stops it passing vacuously against an empty walk. + */ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const SRC_DIR = join(import.meta.dir, "../src"); + +const PATTERNS: RegExp[] = [ + /@arcadeai\/arcadejs/, + /\barcade\.dev\b/, + /\/v1\/tools\/execute/, + /\/v1\/tools\/authorize/, + /oauth\/confirm_user/, +]; + +// `arcade://` is a row url, not a dial. Blanked before matching. +const ROW_URL = /arcade:\/\/\S*/g; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (/\.tsx?$/.test(entry.name)) out.push(full); + } + return out.sort(); +} + +test("no browser code under app/src dials Arcade", () => { + const files = walk(SRC_DIR); + expect(files.length).toBeGreaterThan(50); + const offenders: string[] = []; + for (const file of files) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, i) => { + const text = line.replace(ROW_URL, ""); + if (PATTERNS.some((re) => re.test(text))) { + offenders.push(`${file.slice(SRC_DIR.length + 1)}:${i + 1}`); + } + }); + } + expect(offenders).toEqual([]); +}); From 6a9b466a7d3f1fd40273c28bc83d165142b3c194 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 04:54:17 +0200 Subject: [PATCH 010/232] feat(arcade): make accessFor provider-aware through one brokered vocabulary `row.provenance === "composio"` was a reading of one column that became a claim that there is only one broker, made separately in every module that copied it. It becomes `BrokerProvider` and `brokeredProvenance` here, asked once: the brokered branch answers the transport named after the provider and the toolkit from that provider's own url parser, while `reachedAs` keeps the `schemeKind`/`accountMode` reading, which is our row's and not the broker's. `access.ts` is the one file `brokered-provenance.test.ts` exempts, because it is where the vocabulary lives; the module docblock now says so. No Composio row resolves differently: `composio-access.test.ts` is the evidence. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/access.ts | 110 ++++++++++++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 56a582f86..39eb96baa 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -1,6 +1,7 @@ +import { toolkitOf as arcadeToolkitOf } from "./arcade"; import { schemeKind } from "./broker"; import type { CatalogueEntry } from "./catalogue"; -import { toolkitOf } from "./composio"; +import { toolkitOf as composioToolkitOf } from "./composio"; import type { TransportKind } from "./transport"; /** @@ -23,6 +24,14 @@ import type { TransportKind } from "./transport"; * without filling in this function, and the test beside it enumerates every row shape that exists — * which is the exhaustiveness the previous arrangement could not offer, since nothing connects three * independent string comparisons. + * + * AND THIS IS THE ONE MODULE ALLOWED TO SPELL A BROKER'S NAME, which `brokered-provenance.test.ts` + * enforces by exempting this file and no other. A second broker turned `row.provenance === "composio"` + * from a reading of one column into a claim that there is only one broker — a claim each copy of the + * comparison made separately, in a module that had no reason to be edited when the second one + * arrived. So the name stops being a comparison anybody can write and becomes {@link BrokerProvider} + * and {@link brokeredProvenance}, asked here; everywhere else reads the resolved fields below. The + * exemption is not a privilege, it is the cost of having somewhere for the vocabulary to live. */ /** Whose credential a call goes out on. */ @@ -195,7 +204,83 @@ export class CatalogueTransportUnroutableError extends ServerUnresolvableError { * compile-time door and the runtime one shut, rather than trading the second for the first. */ function isBrokerTransport(kind: TransportKind): boolean { - return kind === "composio"; + return brokeredProvenance(kind); +} + +/** + * Which broker a row is reached through, for a row that is reached through one at all. + * + * A NAMED UNION RATHER THAN THE STRING THE COLUMN HOLDS, because the column holds every provenance + * there is — `first-party`, `custom`, a broker's name — and only these two mean "an app somebody + * enabled at a broker, reached with the deployment's key and kept apart by an id we send". Code that + * compared the column to a literal could not say which of those it meant, so the comparison had to be + * re-derived wherever it was needed, and re-derived wrongly once a second broker existed. + * + * THE VALUES ARE THE COLUMN'S OWN SPELLINGS, not a mapping away from them. `brokered_connections.provider` + * is constrained to exactly `composio` and `arcade`, and `mcp_servers.provenance` writes the same two + * words, so a translation table here would be a second vocabulary to keep in step with the database's + * for no gain. What this type adds is that the compiler now knows the set is closed: a third broker is + * a change to this line, and every `Record` below stops compiling until it is + * answered for. + */ +export type BrokerProvider = "composio" | "arcade"; + +/** + * Whether a provenance names a broker — the one place that question is asked. + * + * CRITERION. No module outside this one compares a provenance or a provider column to a vendor's + * name. `brokered-provenance.test.ts` is what says so, and this predicate is what that test leaves + * callers with instead. + * + * REASON. The comparison it replaces was correct and complete for as long as there was one broker, + * and every copy of it became a silent `false` for Arcade rows on the day there were two — not a + * failure, a row quietly resolving as somebody's MCP endpoint and being dialled as one. A function + * cannot do that: adding a broker to the union above changes every caller's answer at once, and the + * ones that must NOT change are then the ones that have to say so explicitly. + * + * TAKES A `string`, NOT A `BrokerProvider`, because every caller is holding a database column. A + * parameter narrowed to the union would push the unsafe cast back out to each call site, which is + * precisely the thing being collected here. The type predicate is the point: `row.provenance` goes + * in as a string and comes out narrowed, so the tables keyed on the union below are reachable + * without an assertion. + */ +export function brokeredProvenance(p: string): p is BrokerProvider { + return p === "composio" || p === "arcade"; +} + +/** + * Which app at which broker, read from the url by the parser belonging to that broker. + * + * ONE PARSER PER PROVIDER, AND THEY ARE NOT INTERCHANGEABLE. `composio://gmail` and `arcade://Gmail` + * are different schemes naming apps under different rules: Composio's slugs are lower case by + * convention and Arcade's toolkit names are case-sensitive and dotted, so each module's `toolkitOf` + * answers null for the other's url by design. Dispatching on the provider rather than trying both + * parsers is what keeps that null meaning "this row's url does not name an app at its own broker" — + * the thing the brokered gate needs — instead of "no broker recognised it", which would let a row + * provenanced at one broker resolve against the other's parse of the same string. + * + * A `Record` keyed on {@link BrokerProvider} rather than a branch, so a broker added to the union + * cannot be resolved until the url it is dialled at can be read. + */ +const TOOLKIT_OF: Record string | null> = { + composio: composioToolkitOf, + arcade: arcadeToolkitOf, +}; + +/** + * The transport kind a broker is dialled over, which is the provider's own name. + * + * It is a function rather than the bare value only because {@link TransportKind} does not list + * `arcade` yet — the registry in `./transport` is wired by micro-task 5.1, and until it is, the + * assertion here is what stands in for the union having caught up. Nothing else about the mapping is + * in question: a brokered row is dialled over the transport named after its broker, by construction. + * + * TODO(micro-task 5.1): once `TransportKind` includes `arcade`, delete this and declare + * `BrokerProvider` as `Extract`, which makes the identity the + * compiler's claim rather than this comment's. + */ +function brokerTransport(provider: BrokerProvider): TransportKind { + return provider as TransportKind; } /** @@ -207,7 +292,7 @@ function isBrokerTransport(kind: TransportKind): boolean { * from an old backup, written by a bug — cannot turn a reviewed vendor into a brokered one and start * sending its calls somewhere else. The row only ever answers where the catalogue is silent. * - * IT CUTS BOTH WAYS, WHICH IS WHY `composio` IS REFUSED RATHER THAN OVERRULED. Only one direction + * IT CUTS BOTH WAYS, WHICH IS WHY A BROKERED PROVENANCE IS REFUSED RATHER THAN OVERRULED. Only one direction * was considered when that order was written: a brokered row whose id collides with a curated slug * was quietly answered as the curated vendor. Nothing in these two arguments tells that row apart * from a tampered curated one, so the only answer that is not wrong in one of the two worlds is no @@ -234,16 +319,16 @@ export function accessFor( }, entry: CatalogueEntry | null, ): ServerAccess { - if (entry && row.provenance === "composio") { + if (entry && brokeredProvenance(row.provenance)) { throw new ServerRowAmbiguousError( - `${entry.key} is a server this deployment ships an entry for, and a row with that id says its provenance is composio. Nothing can tell an edited column from a brokered app that took the name, so this row is not resolved at all: rename it, or correct its provenance.`, + `${entry.key} is a server this deployment ships an entry for, and a row with that id says its provenance is ${row.provenance}. Nothing can tell an edited column from a brokered app that took the name, so this row is not resolved at all: rename it, or correct its provenance.`, ); } if (entry) { if (isBrokerTransport(entry.transport ?? "mcp")) { throw new CatalogueTransportUnroutableError( - `${entry.key} is a catalogue entry declaring the composio transport, which is reached from a row's provenance and the app slug in its url — neither of which an entry has. There is no app to broker to and no connection to check, so this entry is not resolved at all: give it the transport it is actually reached over.`, + `${entry.key} is a catalogue entry declaring the ${entry.transport ?? "mcp"} transport, which is a broker's and is reached from a row's provenance and the app slug in its url — neither of which an entry has. There is no app to broker to and no connection to check, so this entry is not resolved at all: give it the transport it is actually reached over.`, ); } @@ -255,7 +340,7 @@ export function accessFor( }; } - if (row.provenance === "composio") { + if (brokeredProvenance(row.provenance)) { /* * AND A BROKERED APP THAT NEEDS NO CREDENTIAL IS REACHED AS THE DEPLOYMENT, NOT AS THE ASKER. * @@ -281,15 +366,22 @@ export function accessFor( * asked, the call lands in one account that is nobody's in particular, and naming the asker as * the account would assert an attribution that does not exist. The asker is still on the row as * `actor`. + * + * NEITHER OF THOSE TWO READINGS IS THE BROKER'S, which is why adding a second broker leaves this + * branch alone. `schemeKind` and `accountMode` are columns on OUR row describing what the app was + * created as and whether it was switched to Shared, and both answer the same question about an + * Arcade app that they answer about a Composio one. Only WHICH broker and WHICH app are the + * provider's business, and those are the two fields below. */ + const provider = row.provenance; return { - transport: "composio", + transport: brokerTransport(provider), credential: "brokered", reachedAs: schemeKind(row.authScheme) === "none" || row.accountMode === "shared" ? "deployment" : "person", - toolkit: toolkitOf(row.url), + toolkit: TOOLKIT_OF[provider](row.url), }; } From ceb623f45bc0cbf2b67057ccb60c5ab91357d93a Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:02:21 +0200 Subject: [PATCH 011/232] fix(arcade): satisfy format:check, exclude recorded Arcade fixture from Biome Format index.tsx; exclude the vendor-recorded arcade-catalogue.json so it stays byte-identical to its upstream source. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/routes/_authed/settings/connected-accounts/index.tsx | 3 +-- biome.json | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index c1245a879..91360247d 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -98,8 +98,7 @@ export function sharedAccountsListedOn( servers: PluginServer[], ): PluginServer[] { return servers.filter( - (server) => - isBrokered(server) && server.accountMode === "shared", + (server) => isBrokered(server) && server.accountMode === "shared", ); } diff --git a/biome.json b/biome.json index 4bdd1512b..1544b50fe 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,8 @@ "!app/src/routeTree.gen.ts", "!desktop/src-tauri/gen", "!**/target", - "!server/drizzle" + "!server/drizzle", + "!server/tests/fixtures/arcade-catalogue.json" ] }, "formatter": { From b765ec7033fc0accc663439277fcd90f04e8d105 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:02:37 +0200 Subject: [PATCH 012/232] refactor(arcade): single home for BrokerProvider, make config assertions non-vacuous config.ts re-declared the type that plugins/access.ts owns; import it type-only instead. Assert the Arcade fields exist and are unset rather than merely reading undefined. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/config.ts | 3 +-- server/tests/config.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index fde12336b..3723dd370 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -16,6 +16,7 @@ import { type TranscriptionConfig, transcriptionConfig, } from "./dictation/config"; +import type { BrokerProvider } from "./plugins/access"; import { type VoiceConfig, voiceConfig } from "./voice/config"; export type RuntimeCapabilities = { @@ -394,8 +395,6 @@ function optional(environment: Environment, name: string): string | undefined { return environment[name]?.trim() || undefined; } -export type BrokerProvider = "composio" | "arcade"; - function brokerProvider(environment: Environment): BrokerProvider | undefined { const raw = optional(environment, "OPENBOT_BROKER_PROVIDER"); if (raw === undefined) return undefined; diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index ef28691aa..a347aa3d8 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -1135,8 +1135,8 @@ test("a Composio key is read when set and absent when not", () => { test("an Arcade key and a broker provider are read, and an unknown provider is refused", () => { const unset = loadConfig(baseEnvironment); - expect(unset.arcadeApiKey).toBeUndefined(); - expect(unset.brokerProvider).toBeUndefined(); + expect(unset).toHaveProperty("arcadeApiKey", undefined); + expect(unset).toHaveProperty("brokerProvider", undefined); const set = loadConfig({ ...baseEnvironment, ARCADE_API_KEY: " arc_example ", From 3cc188bb79b079df50960f86fa0a48025ae47537 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:02:40 +0200 Subject: [PATCH 013/232] chore(arcade): restore seam-probe exhaustiveness and fix toolkitOf docblock Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade.ts | 10 ++++++---- server/tests/composio-adapter.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 6367d9da6..648191c94 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -10,11 +10,15 @@ */ export const listNeedsCredential = false; +const TOOLKIT_NAME = /^[A-Za-z0-9_-]+$/; + /** * Which toolkit this connection is about, from `arcade://Toolkit`. * - * CASE IS PRESERVED AND NOTHING IS NORMALIZED. Arcade's toolkit and action names are case-sensitive - * and dotted, where Composio's slugs are neither, so a lowercased toolkit reaches nothing: + * CASE IS PRESERVED AND NOTHING IS NORMALIZED. Arcade's toolkit and action names are case-sensitive, + * and action names are dotted (`Apollo.EnrichOrganization`: the dot separates toolkit from action, + * so a toolkit name never contains one and `TOOLKIT_NAME` rightly rejects it), where Composio's + * slugs are neither, so a lowercased toolkit reaches nothing: * `arcade://Gmail` and `arcade://gmail` are distinct apps. * * ONE TOOLKIT OR NOTHING. This answer is the name the brokered gate looks a person's row up by, so a @@ -24,8 +28,6 @@ export const listNeedsCredential = false; * TRIMMED BEFORE THE SLASHES COME OFF, as `composio.ts` does: the other order leaves a space as the * last character, so the slash is not at the end and nothing matches. */ -const TOOLKIT_NAME = /^[A-Za-z0-9_-]+$/; - export function toolkitOf(url: string): string | null { const prefix = "arcade://"; if (!url.startsWith(prefix)) return null; diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 44849b49c..144c16a46 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -3272,6 +3272,13 @@ describe("what a vendor failure becomes on its way out of the seam", () => { expect( [...methodsOf(seam.broker), ...methodsOf(seam.actions)].sort(), ).toEqual(SEAM_CASES.map((seamCase) => seamCase.method).sort()); + // Whatever is not a method must be acknowledged by name, so a new non-function member of the + // broker is a decision somebody makes rather than a thing the filter above silently drops. + expect( + Object.entries(seam.broker) + .filter(([, member]) => typeof member !== "function") + .map(([name]) => name), + ).toEqual(["grantIsLocal"]); }); for (const seamCase of SEAM_CASES) { From 9bf79b6b2382c6310c56f9dc1b4120337b68c1fb Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:08:17 +0200 Subject: [PATCH 014/232] feat(arcade): classify tool behavior destructive-first and fail closed Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade.ts | 29 +++++++++++++++++ server/tests/arcade-classify.test.ts | 47 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 server/tests/arcade-classify.test.ts diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 648191c94..f0d88e46f 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -4,6 +4,8 @@ * records that the other direction is a cycle). */ +import { flagOf } from "./broker"; + /** * Whether listing tools needs somebody's credential. It does not: the listing uses the deployment's * own Arcade key, and a listing is not a person's. @@ -34,3 +36,30 @@ export function toolkitOf(url: string): string | null { const name = url.slice(prefix.length).trim().replace(/\/+$/, ""); return TOOLKIT_NAME.test(name) ? name : null; } + +/** + * What a tool does, from `metadata.behavior`. DESTRUCTIVE IS READ FIRST, and the order is the + * decision: a reading that checked `read_only` first would record a both-flagged tool as a READ, + * the one direction nothing downstream recovers from, since nothing ever asks again. + * + * ABSENT METADATA, OR A FLAG THAT `flagOf` ANSWERS NULL (a vendor publishing a string where it + * documents a boolean), IS A DESTRUCTIVE WRITE, which is also what a listing that would not say + * already records in `./mcp`. It fails safe by putting an approval in front of a read, which is + * annoying and correct; the alternative lets a deletion through unannounced. + */ +export function classify(metadata: unknown): { + effect: "read" | "write"; + destructive: boolean; +} { + const closed = { effect: "write", destructive: true } as const; + if (typeof metadata !== "object" || metadata === null) return closed; + const behavior = (metadata as { behavior?: unknown }).behavior; + if (typeof behavior !== "object" || behavior === null) return closed; + const flags = behavior as { destructive?: unknown; read_only?: unknown }; + const destructive = flagOf(flags.destructive, false); + const readOnly = flagOf(flags.read_only, false); + if (destructive === null || readOnly === null) return closed; + if (destructive) return { effect: "write", destructive: true }; + if (readOnly) return { effect: "read", destructive: false }; + return { effect: "write", destructive: false }; +} diff --git a/server/tests/arcade-classify.test.ts b/server/tests/arcade-classify.test.ts new file mode 100644 index 000000000..af34b7e42 --- /dev/null +++ b/server/tests/arcade-classify.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { classify } from "../src/plugins/arcade"; + +/** + * What an Arcade tool does, read from `metadata.behavior`. `destructive` is read FIRST: a reading + * that checked `read_only` first would record a both-flagged tool as a read, the one direction + * nothing downstream recovers from. Anything that cannot be read fails closed to a destructive + * write, which costs an approval in front of a read rather than a deletion let through unannounced. + */ +describe("classify", () => { + test("destructive is a destructive write", () => { + expect(classify({ behavior: { destructive: true } })).toEqual({ + effect: "write", + destructive: true, + }); + }); + + test("read_only alone is a read", () => { + expect(classify({ behavior: { read_only: true } })).toEqual({ + effect: "read", + destructive: false, + }); + }); + + test("both flags true is a destructive write, never a read", () => { + expect( + classify({ behavior: { destructive: true, read_only: true } }), + ).toEqual({ effect: "write", destructive: true }); + }); + + test("neither flag true is a plain write", () => { + expect( + classify({ behavior: { destructive: false, read_only: false } }), + ).toEqual({ effect: "write", destructive: false }); + }); + + test("absent metadata fails closed", () => { + expect(classify(undefined)).toEqual({ effect: "write", destructive: true }); + }); + + test("a string-valued flag fails closed rather than coercing", () => { + expect(classify({ behavior: { read_only: "true" } })).toEqual({ + effect: "write", + destructive: true, + }); + }); +}); From dddac3a2fcb69c1a06e31bda2eb3c2f2f98daf2e Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:08:25 +0200 Subject: [PATCH 015/232] feat(arcade): resolve an Arcade row's account through the brokered vocabulary accountFor returned no account for any non-composio row, so an Arcade row lost its identity resolution silently in the gate, transport, audit and memory sync alike. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/shared-accounts.ts | 3 ++- server/tests/shared-accounts.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/shared-accounts.ts b/server/src/plugins/shared-accounts.ts index 3792413f9..aeaa2562a 100644 --- a/server/src/plugins/shared-accounts.ts +++ b/server/src/plugins/shared-accounts.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { brokeredProvenance } from "./access"; import { type Decides, type SchemeKind, schemeKind } from "./broker"; /** @@ -39,7 +40,7 @@ export function accountFor( }, credentialActorId: string, ): AccountAnswer { - if (row.provenance !== "composio") return { kind: "none" }; + if (!brokeredProvenance(row.provenance)) return { kind: "none" }; const kind = schemeKind(row.authScheme); type _AccountForDecides = Decides< diff --git a/server/tests/shared-accounts.test.ts b/server/tests/shared-accounts.test.ts index c8355ac5f..417aeb95d 100644 --- a/server/tests/shared-accounts.test.ts +++ b/server/tests/shared-accounts.test.ts @@ -54,6 +54,32 @@ describe("accountFor", () => { }); }); +describe("accountFor for an Arcade row", () => { + const arcade = (accountMode: string | null) => ({ + ...brokered(accountMode), + provenance: "arcade", + }); + + test("a personal Arcade row resolves to the credential actor's own account", () => { + expect(accountFor(arcade("personal"), "user_a")).toEqual({ + kind: "person", + userId: "user_a", + }); + }); + + test("a shared Arcade row resolves to the deployment's account", () => { + expect(accountFor(arcade("shared"), "user_a")).toEqual({ + kind: "deployment", + }); + }); + + test("a non-brokered row still has no account", () => { + expect( + accountFor({ ...brokered("personal"), provenance: "manual" }, "user_a"), + ).toEqual({ kind: "none" }); + }); +}); + describe("asAccountMode", () => { test("reads the two modes and nothing else", () => { expect(asAccountMode("personal")).toBe("personal"); From 543c8efadc55bc2e2ac97ddfede8312aaa5288c2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:08:25 +0200 Subject: [PATCH 016/232] feat(arcade): activeProvider selects the broker from config alone Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/broker.ts | 23 +++++++++++++ server/tests/provider-selection.test.ts | 44 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 server/tests/provider-selection.test.ts diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index e9c0dfdbc..1aea8e2c0 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -13,6 +13,7 @@ * the import graph of every test that touches enablement. `AccountRef`, below, is the one import * that crosses this file's boundary, and it is a type this deployment names, never the vendor's. */ +import type { BrokerProvider } from "./access"; import type { AccountRef } from "./shared-accounts"; /** @@ -762,3 +763,25 @@ function webAddress(value: string): URL | null { export function brokerSentence(error: unknown): string | null { return error instanceof BrokerRefusalError ? error.message : null; } + +/** + * Which broker this deployment runs, from configuration alone (no database access). + * + * One key set selects that provider. Both set is legal and expected, since ending an account at + * the old vendor needs its key, so `brokerProvider` decides and unset means `null`. A + * `brokerProvider` naming a provider whose key is unset is refused (`null`), never selected. + */ +export function activeProvider(c: { + composioApiKey?: string; + arcadeApiKey?: string; + brokerProvider?: BrokerProvider; +}): BrokerProvider | null { + const composio = c.composioApiKey !== undefined; + const arcade = c.arcadeApiKey !== undefined; + const configured: BrokerProvider[] = []; + if (composio) configured.push("composio"); + if (arcade) configured.push("arcade"); + const named = c.brokerProvider; + if (named !== undefined) return configured.includes(named) ? named : null; + return configured.length === 1 ? configured[0] : null; +} diff --git a/server/tests/provider-selection.test.ts b/server/tests/provider-selection.test.ts new file mode 100644 index 000000000..a7d138407 --- /dev/null +++ b/server/tests/provider-selection.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { activeProvider } from "../src/plugins/broker"; + +/** + * Which broker is active, decided from configuration alone: no network, no database. + * + * Both keys set is legal and expected, since ending an account at the old vendor needs its key. + */ +describe("activeProvider", () => { + test("exactly one key set selects that provider", () => { + expect(activeProvider({ composioApiKey: "c" })).toBe("composio"); + expect(activeProvider({ arcadeApiKey: "a" })).toBe("arcade"); + }); + + test("both keys set: brokerProvider decides", () => { + const keys = { composioApiKey: "c", arcadeApiKey: "a" }; + expect(activeProvider({ ...keys, brokerProvider: "composio" })).toBe( + "composio", + ); + expect(activeProvider({ ...keys, brokerProvider: "arcade" })).toBe( + "arcade", + ); + }); + + test("both keys set with brokerProvider unset selects nothing", () => { + expect(activeProvider({ composioApiKey: "c", arcadeApiKey: "a" })).toBe( + null, + ); + }); + + test("neither key set selects nothing", () => { + expect(activeProvider({})).toBe(null); + expect(activeProvider({ brokerProvider: "arcade" })).toBe(null); + }); + + test("a brokerProvider whose key is unset is refused, not selected", () => { + expect( + activeProvider({ composioApiKey: "c", brokerProvider: "arcade" }), + ).toBe(null); + expect( + activeProvider({ arcadeApiKey: "a", brokerProvider: "composio" }), + ).toBe(null); + }); +}); From 7861bde6829625006a9d7519a2d7bb6a5ba8ca17 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:08:34 +0200 Subject: [PATCH 017/232] feat(arcade): add pending_authorizations keyed by the signed-in person Co-Authored-By: Claude Opus 5 (1M context) --- server/drizzle/0054_spicy_wallflower.sql | 14 + server/drizzle/meta/0054_snapshot.json | 9581 ++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 33 + server/tests/arcade-attempt-schema.test.ts | 83 + 5 files changed, 9718 insertions(+) create mode 100644 server/drizzle/0054_spicy_wallflower.sql create mode 100644 server/drizzle/meta/0054_snapshot.json create mode 100644 server/tests/arcade-attempt-schema.test.ts diff --git a/server/drizzle/0054_spicy_wallflower.sql b/server/drizzle/0054_spicy_wallflower.sql new file mode 100644 index 000000000..5a8a947c0 --- /dev/null +++ b/server/drizzle/0054_spicy_wallflower.sql @@ -0,0 +1,14 @@ +CREATE TABLE "pending_authorizations" ( + "actor_id" text PRIMARY KEY NOT NULL, + "server_id" text NOT NULL, + "action" text NOT NULL, + "holder" text NOT NULL, + "user_id" text, + "vendor_user_id" text NOT NULL, + "auth_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone, + "outcome" text +); +--> statement-breakpoint +ALTER TABLE "pending_authorizations" ADD CONSTRAINT "pending_authorizations_server_id_mcp_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/server/drizzle/meta/0054_snapshot.json b/server/drizzle/meta/0054_snapshot.json new file mode 100644 index 000000000..8df9988b9 --- /dev/null +++ b/server/drizzle/meta/0054_snapshot.json @@ -0,0 +1,9581 @@ +{ + "id": "4624420b-3839-472e-92e1-6eba2dad8a93", + "prevId": "fd489e70-85c6-4230-ad16-14807dd2c1fe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "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": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "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": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_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": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_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": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index a385394a8..12872ede0 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -379,6 +379,13 @@ "when": 1791773482676, "tag": "0053_adorable_frank_castle", "breakpoints": true + }, + { + "idx": 54, + "version": "7", + "when": 1791774497487, + "tag": "0054_spicy_wallflower", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index c2fe62576..1093eebfa 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -443,6 +443,39 @@ export const brokeredConnections = pgTable( ], ); +/** + * One in-flight Arcade authorization: what the returning browser is verified against. + * + * `actor_id` IS THE SIGNED-IN PERSON'S `users.id` — the session subject — AND NEVER + * `credentialActorId`. `store.ts` reads `input.credentialActorId ?? input.actorId`, and on a Team + * Bot those differ, and neither is the person holding the browser. The verifier finds this attempt + * from the returning browser's session, so any other key writes attempts a browser can never match. + * + * The key is the actor alone, so a person has at most one attempt at a time. A PRIMARY KEY with + * "expired is treated as absent" would be a permanent 409, because the expired row still occupies + * the key. Starting an attempt is therefore one `INSERT ... ON CONFLICT (actor_id) DO UPDATE ... + * WHERE created_at < now() - interval '15 minutes' OR completed_at IS NOT NULL`: a live attempt is + * refused, an expired or completed one is taken over, and nobody is ever locked out. + * + * `completed_at` is what makes completion happen exactly once: the verifier claims the row by + * setting it, and a second return finds it set. `auth_id` is what binds a callback to this attempt; + * an attempt holding null can never complete. + */ +export const pendingAuthorizations = pgTable("pending_authorizations", { + actorId: text("actor_id").primaryKey(), + serverId: text("server_id") + .notNull() + .references(() => mcpServers.id, { onDelete: "cascade" }), + action: text("action").notNull(), + holder: text("holder").$type<"person" | "deployment">().notNull(), + userId: text("user_id"), + vendorUserId: text("vendor_user_id").notNull(), + authId: text("auth_id"), + createdAt: createdAt(), + completedAt: timestamp("completed_at", { withTimezone: true }), + outcome: text("outcome").$type<"completed" | "failed" | "unreconciled">(), +}); + /** * One person's grant on one MCP server: the row that makes a Bot answer as the asker. * diff --git a/server/tests/arcade-attempt-schema.test.ts b/server/tests/arcade-attempt-schema.test.ts new file mode 100644 index 000000000..2df7f0138 --- /dev/null +++ b/server/tests/arcade-attempt-schema.test.ts @@ -0,0 +1,83 @@ +// server/tests/arcade-attempt-schema.test.ts +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { mcpServers } from "../src/db/schema/plugins"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const serverId = `arcade-attempt-${suite}`; + +/** The one statement a start-attempt route issues: take the key if free, expired, or completed. */ +const claim = (actorId: string, authId: string) => + database.execute(sql` + INSERT INTO pending_authorizations + (actor_id, server_id, action, holder, user_id, vendor_user_id, auth_id) + VALUES (${actorId}, ${serverId}, 'connect', 'person', ${actorId}, ${`v-${actorId}`}, ${authId}) + ON CONFLICT (actor_id) DO UPDATE SET + server_id = EXCLUDED.server_id, + action = EXCLUDED.action, + holder = EXCLUDED.holder, + user_id = EXCLUDED.user_id, + vendor_user_id = EXCLUDED.vendor_user_id, + auth_id = EXCLUDED.auth_id, + created_at = now(), + completed_at = NULL, + outcome = NULL + WHERE pending_authorizations.created_at < now() - interval '15 minutes' + OR pending_authorizations.completed_at IS NOT NULL + RETURNING * + `); + +beforeAll(async () => { + await database.insert(mcpServers).values({ + id: serverId, + title: "Attempt schema", + vendor: "test", + url: "https://example.invalid/mcp", + }); +}); + +afterAll(async () => { + await database.execute( + sql`delete from mcp_servers where id = ${serverId}`, + ); +}); + +describe("pending_authorizations", () => { + test("a second attempt for one actor is refused while the first is live", async () => { + const actor = `live-${suite}`; + expect((await claim(actor, "auth-1")).length).toBe(1); + expect((await claim(actor, "auth-2")).length).toBe(0); + const rows = await database.execute( + sql`select auth_id from pending_authorizations where actor_id = ${actor}`, + ); + expect(rows[0]?.auth_id).toBe("auth-1"); + }); + + test("an expired attempt is replaceable in one statement", async () => { + const actor = `expired-${suite}`; + expect((await claim(actor, "auth-old")).length).toBe(1); + await database.execute( + sql`update pending_authorizations set created_at = now() - interval '16 minutes' where actor_id = ${actor}`, + ); + const replaced = await claim(actor, "auth-new"); + expect(replaced.length).toBe(1); + expect(replaced[0]?.auth_id).toBe("auth-new"); + }); + + test("a completed attempt is replaceable", async () => { + const actor = `done-${suite}`; + expect((await claim(actor, "auth-old")).length).toBe(1); + await database.execute( + sql`update pending_authorizations set completed_at = now(), outcome = 'completed' where actor_id = ${actor}`, + ); + const replaced = await claim(actor, "auth-new"); + expect(replaced.length).toBe(1); + expect(replaced[0]?.auth_id).toBe("auth-new"); + expect(replaced[0]?.completed_at).toBeNull(); + expect(replaced[0]?.outcome).toBeNull(); + }); +}); From 1ebf35f8b1506a11a0c9c95b763098e170961684 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:08:39 +0200 Subject: [PATCH 018/232] refactor(arcade): parameterise the brokered logo backfill on the provider backfillComposioLogos -> backfillBrokeredLogos(database, provider, broker). The provider drives both the select and the guarded update, and the url parser is chosen per provider. index.ts passes "composio", a no-op for existing deployments. Guard: add the drizzle spelling `x.provenance, "composio"` to brokered-provenance.test.ts. Before the fix it named logos.ts:15 and logos.ts:32; after, it names no logos.ts line. Still red on store.ts (11 lines, 2.3) and shared-accounts.ts:42 (2.4); addBrokeredApp is 3.2. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/index.ts | 14 ++++++++------ server/src/plugins/logos.ts | 21 +++++++++++++++++---- server/tests/brokered-provenance.test.ts | 4 ++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index d038b09cd..efa5d1b5c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -195,7 +195,7 @@ import { createAccountModeSwitch } from "./plugins/account-mode"; import { useRoutineTools } from "./plugins/builtin-routines"; import { useComposioClient } from "./plugins/composio"; import { createComposioClient } from "./plugins/composio-adapter"; -import { backfillComposioLogos } from "./plugins/logos"; +import { backfillBrokeredLogos } from "./plugins/logos"; import { redirectUriFor } from "./plugins/oauth"; import { createSharedUseGate, @@ -782,11 +782,13 @@ const accountModes = createAccountModeSwitch({ // Logo metadata is optional; a vendor outage must not prevent the API from starting. if (composio) { - void backfillComposioLogos(database, composio.broker).catch(() => { - console.warn( - "Composio app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.", - ); - }); + void backfillBrokeredLogos(database, "composio", composio.broker).catch( + () => { + console.warn( + "Composio app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.", + ); + }, + ); } /** diff --git a/server/src/plugins/logos.ts b/server/src/plugins/logos.ts index c2f4a9936..56b9ca5a1 100644 --- a/server/src/plugins/logos.ts +++ b/server/src/plugins/logos.ts @@ -1,18 +1,31 @@ import { and, eq, isNull } from "drizzle-orm"; import type { Database } from "../db/client"; import { mcpServers } from "../db/schema"; +import type { BrokerProvider } from "./access"; +import { toolkitOf as arcadeToolkitOf } from "./arcade"; import type { ComposioBroker } from "./broker"; -import { toolkitOf } from "./composio"; +import { toolkitOf as composioToolkitOf } from "./composio"; + +/** + * The url parser that matches the provider a row is provenanced at. The two schemes name apps under + * different rules, so a row is read by its own broker's parser or not at all. + */ +const TOOLKIT_OF: Record string | null> = { + composio: composioToolkitOf, + arcade: arcadeToolkitOf, +}; /** Fill older app records without changing their connections, schemes, or grants. */ -export async function backfillComposioLogos( +export async function backfillBrokeredLogos( database: Database, + provider: BrokerProvider, broker: Pick, ) { + const toolkitOf = TOOLKIT_OF[provider]; const missing = await database .select({ id: mcpServers.id, url: mcpServers.url }) .from(mcpServers) - .where(and(eq(mcpServers.provenance, "composio"), isNull(mcpServers.logo))); + .where(and(eq(mcpServers.provenance, provider), isNull(mcpServers.logo))); if (missing.length === 0) return; const logos = new Map( @@ -29,7 +42,7 @@ export async function backfillComposioLogos( and( eq(mcpServers.id, row.id), eq(mcpServers.url, row.url), - eq(mcpServers.provenance, "composio"), + eq(mcpServers.provenance, provider), isNull(mcpServers.logo), ), ); diff --git a/server/tests/brokered-provenance.test.ts b/server/tests/brokered-provenance.test.ts index 068cb4727..cf9bca09a 100644 --- a/server/tests/brokered-provenance.test.ts +++ b/server/tests/brokered-provenance.test.ts @@ -23,6 +23,10 @@ const PATTERNS: RegExp[] = [ /provenance\s*[!=]==\s*"(composio|arcade)"/, /brokeredConnections\.provider\s*,\s*"(composio|arcade)"/, /provider:\s*"(composio|arcade)"/, + // `eq(mcpServers.provenance, "composio")`: the drizzle spelling of the same mistake as the + // `provenance === "composio"` pattern above. A guard catching one spelling of a mistake and not + // the other is worse than none, because it is read as coverage. + /(mcpServers|\w+)\.provenance\s*,\s*"(composio|arcade)"/, ]; test("no plugin module hard-codes a brokered vendor name outside access.ts", () => { From b727e6b4ba3652ec19301755ac5b41c22542f91a Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:12:53 +0200 Subject: [PATCH 019/232] refactor(arcade): thread the active provider through store.ts's brokered reads and writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `store.ts` asked "is this row Composio's?" in eleven places, each by comparing to the literal. Every one of them was correct with one broker and silently wrong with two: an Arcade deployment would read an empty slice of `brokered_connections`, never fire the "the other provider holds rows" refusal, and sweep the wrong slice at offboarding — all without an error. The vendor is now named once, as `PluginStoreOptions.brokerProvider`, resolved to a store-scope `brokerProvider: BrokerProvider` that every site below reads. `BrokerProvider` is imported from `./access`, which is the single home for the vocabulary; no second copy is declared here. The option is optional only because nothing passes it yet (`activeProvider` in `./broker` is what will), and the fallback to `composio` sits at that one deployment boundary and nowhere else — no function below defaults it. `recordBrokeredConnection`'s insert now stamps the store's own provider, which is the broker the connection it is recording was actually made at. That is the highest-stakes line here: it is the only one whose failure mode is silence. Offender lines cleared (old numbering): 1439, 1497, 1503, 2613, 3715, 3764, 4357, 4540, 5435, 5777. Still red: store.ts:3478 (was 3445), inside `addBrokeredApp`, which micro-task 3.2 owns and this change deliberately did not touch. `shared-accounts.ts:42` and the `logos.ts` sites recorded earlier are already gone, cleared by 2.4 and 2.5 landing alongside this. No Composio behaviour changes: threading `"composio"` through is a no-op on every existing deployment, and the Composio unit (534) and database (177) suites are green. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 67 +++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index ed479568d..f5ace7425 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -46,6 +46,7 @@ import { import type { CheckPrivateShare } from "../proactive/private-share"; import { accessFor, + type BrokerProvider, type ServerAccess, ServerUnresolvableError, } from "./access"; @@ -1188,6 +1189,30 @@ export type PluginStoreOptions = { * above — invent a broker to get a store. */ broker?: ComposioBroker; + /** + * Which broker this deployment runs, which is the slice of `brokered_connections` this store + * reads and the word its connection writer stamps on every row it mints. + * + * THE ONE PLACE THE VENDOR IS NAMED. Every read of `brokered_connections` below and the insert in + * {@link createPluginStore}'s `recordBrokeredConnection` used to spell `composio` for themselves — + * eleven comparisons that were each correct while there was one broker and each silently wrong on + * the day there were two. They now all read this. A deployment running Arcade whose store was + * still filtering on `composio` would not fail: it would read an empty slice, write rows nobody + * reads back, and leave the "the other provider holds rows" refusal permanently unfired while the + * offboarding sweep swept the wrong table slice. That is the failure this field exists to make + * impossible to reach by accident. + * + * OPTIONAL ONLY BECAUSE NOTHING PASSES IT YET, AND THE FALLBACK IS THE ONLY VENDOR ANY DEPLOYMENT + * RUNS TODAY. `activeProvider` in `./broker` is what decides this from configuration, and wiring + * it through the routes that build a store is a later change; until then every store is a Composio + * store and `composio` is what it always wrote. The fallback is HERE, at the deployment boundary + * where the answer is knowable, and nowhere else: no function below defaults it, because a default + * deeper in is a second answer to this question that nothing holds equal to this one. + * + * TODO: pass `activeProvider(config)` at every `createPluginStore` call and make this required, so + * that an Arcade deployment cannot be built by forgetting a field. + */ + brokerProvider?: BrokerProvider; /** Where the vendor sends people back; needed to (re)register a dynamic client. */ redirectUri?: string; /** @@ -1234,6 +1259,13 @@ export function createPluginStore(options: PluginStoreOptions) { // No default, unlike the seams above: there is no real implementation in this tree to fall back // to, and a deployment with no Composio key is supposed to have no broker. See `./broker`. const broker = options.broker; + /* + * The broker this store is for, resolved once so that every read of `brokered_connections`, every + * `provenance` test and the connection writer below are reading ONE value rather than each + * re-deciding. See {@link PluginStoreOptions.brokerProvider} for why the fallback is here and only + * here. + */ + const brokerProvider: BrokerProvider = options.brokerProvider ?? "composio"; /* * One exchange at a time per (server, person). A rotating vendor invalidates the refresh @@ -1436,7 +1468,7 @@ export function createPluginStore(options: PluginStoreOptions) { * so only a removal racing this read gets here, and it is refused as the missing row is above. */ const toolkit = - dialled.provenance === "composio" ? toolkitOf(dialled.url) : null; + dialled.provenance === brokerProvider ? toolkitOf(dialled.url) : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; if (toolkit && !app) return { @@ -1494,13 +1526,13 @@ export function createPluginStore(options: PluginStoreOptions) { const accountRow = (toolkit: string, account: AccountRef) => account.holder === "person" ? and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), eq(brokeredConnections.holder, "person"), eq(brokeredConnections.userId, account.userId), ) : and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), eq(brokeredConnections.holder, "deployment"), ); @@ -2610,7 +2642,8 @@ export function createPluginStore(options: PluginStoreOptions) { * with it, so the audience gate decides on and keys its approval by the row that answers rather * than the one that was dialled. Null for every row that is not a brokered app. */ - const toolkit = row.provenance === "composio" ? toolkitOf(row.url) : null; + const toolkit = + row.provenance === brokerProvider ? toolkitOf(row.url) : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; const access = accessFor( app @@ -3712,7 +3745,7 @@ export function createPluginStore(options: PluginStoreOptions) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), ), ) @@ -3761,7 +3794,7 @@ export function createPluginStore(options: PluginStoreOptions) { .delete(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), ), ); @@ -4354,7 +4387,7 @@ export function createPluginStore(options: PluginStoreOptions) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.holder, "deployment"), ), ) @@ -4537,7 +4570,7 @@ export function createPluginStore(options: PluginStoreOptions) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), ), ); @@ -5432,7 +5465,7 @@ export function createPluginStore(options: PluginStoreOptions) { .leftJoin(users, eq(users.id, brokeredConnections.connectedBy)) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), eq(brokeredConnections.holder, "deployment"), ), @@ -5774,7 +5807,21 @@ export function createPluginStore(options: PluginStoreOptions) { await database .insert(brokeredConnections) .values({ - provider: "composio", + /* + * THE BROKER THIS ROW WAS ACTUALLY MINTED AT, which is this store's own, because this + * insert only ever runs behind a connection just made at the broker this store holds. + * + * CRITERION. A row inserted here carries the provider whose grant it stands for, so the + * reads keyed on `provider` above find exactly the rows this writer wrote. + * + * REASON. The literal `composio` here was the one line that could not fail loudly. An + * Arcade deployment writing it would read its own rows back through a `composio` filter + * and APPEAR TO WORK, while the refusal that is supposed to fire when the other provider + * holds rows never fired — nothing would ever be filed under the other name — and the + * offboarding sweep, keyed the same way, would sweep a slice of the table that was not + * this deployment's. Every symptom of that is silence. + */ + provider: brokerProvider, app: input.toolkit, holder: input.account.holder, userId: From 50faa45b7ea223cffbf50b0958d490db3ca5421d Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:14:00 +0200 Subject: [PATCH 020/232] style(arcade): format the pending_authorizations schema test Formatter-only change to the test landed in 7861bde. No assertions changed. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-attempt-schema.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/tests/arcade-attempt-schema.test.ts b/server/tests/arcade-attempt-schema.test.ts index 2df7f0138..945a85ada 100644 --- a/server/tests/arcade-attempt-schema.test.ts +++ b/server/tests/arcade-attempt-schema.test.ts @@ -41,9 +41,7 @@ beforeAll(async () => { }); afterAll(async () => { - await database.execute( - sql`delete from mcp_servers where id = ${serverId}`, - ); + await database.execute(sql`delete from mcp_servers where id = ${serverId}`); }); describe("pending_authorizations", () => { From b4ef5d8a6f7866b2b5be7898bb5ae1869a81d651 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:26:01 +0200 Subject: [PATCH 021/232] fix(arcade): repair the logos test import, drop a stale guard gap note, correct the broker docblock Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/broker.ts | 4 +-- server/tests/brokered-provenance.test.ts | 8 +---- server/tests/plugin-logos.integration.test.ts | 30 +++++++++++++++---- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 1aea8e2c0..fd122d23c 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -10,8 +10,8 @@ * schema. Everything below is a name for a shape, so the vendor's package stays confined to the * adapter that implements {@link ConnectedAppBroker} — one file, replaceable, and the only place a * version bump can reach. A module that named the vendor's types here would put their package on - * the import graph of every test that touches enablement. `AccountRef`, below, is the one import - * that crosses this file's boundary, and it is a type this deployment names, never the vendor's. + * the import graph of every test that touches enablement. `AccountRef` and `BrokerProvider`, below, are the only imports + * that cross this file's boundary, and both are types this deployment names, never the vendor's. */ import type { BrokerProvider } from "./access"; import type { AccountRef } from "./shared-accounts"; diff --git a/server/tests/brokered-provenance.test.ts b/server/tests/brokered-provenance.test.ts index cf9bca09a..a5ba7dc13 100644 --- a/server/tests/brokered-provenance.test.ts +++ b/server/tests/brokered-provenance.test.ts @@ -5,13 +5,7 @@ * * `access.ts` is the one exempt file because it is where the vocabulary lives. * - * KNOWN GAP: the regexes below do NOT catch - * `eq(mcpServers.provenance, "composio")` (two sites in `logos.ts`). - * Micro-task 2.5 will add that spelling. A guard that catches one spelling of - * a mistake and not the other is worse than none, because it is read as - * coverage. - * - * EXPECTED TO FAIL until micro-tasks 2.3, 2.5 and 3.2 clear the offenders. + * EXPECTED TO FAIL until micro-task 3.2 clears the last offender. */ import { expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; diff --git a/server/tests/plugin-logos.integration.test.ts b/server/tests/plugin-logos.integration.test.ts index 0c05c4b42..ad9b137ee 100644 --- a/server/tests/plugin-logos.integration.test.ts +++ b/server/tests/plugin-logos.integration.test.ts @@ -4,7 +4,7 @@ import { eq } from "drizzle-orm"; import { createDatabase } from "../src/db/client"; import { mcpServers } from "../src/db/schema"; import type { BrokerApp } from "../src/plugins/broker"; -import { backfillComposioLogos } from "../src/plugins/logos"; +import { backfillBrokeredLogos } from "../src/plugins/logos"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -17,13 +17,17 @@ afterAll(async () => { await database.$client.close(); }); -async function server(logo: string | null, provenance = "composio") { +async function server( + logo: string | null, + provenance = "composio", + urlOf = (id: string) => `composio://${id}`, +) { const id = `logo-${randomUUID()}`; await database.insert(mcpServers).values({ id, title: "Example", vendor: "Composio", - url: `composio://${id}`, + url: urlOf(id), provenance, logo, authScheme: "API_KEY", @@ -54,7 +58,7 @@ test("fills existing brokered apps by their URL, preserving existing logos and s const missing = await server(null); const existing = await server("https://example.com/existing.svg"); const custom = await server(null, "custom"); - await backfillComposioLogos(database, { + await backfillBrokeredLogos(database, "composio", { listApps: async () => [app(missing), app(existing), app(custom)], }); expect((await row(missing))?.logo).toBe("https://example.com/logo.svg"); @@ -66,7 +70,7 @@ test("fills existing brokered apps by their URL, preserving existing logos and s test("catalogue errors preserve the saved app and propagate to the startup warning", async () => { const id = await server(null); await expect( - backfillComposioLogos(database, { + backfillBrokeredLogos(database, "composio", { listApps: async () => { throw new Error("Catalogue unavailable"); }, @@ -75,3 +79,19 @@ test("catalogue errors preserve the saved app and propagate to the startup warni expect((await row(id))?.logo).toBeNull(); expect((await row(id))?.authScheme).toBe("API_KEY"); }); + +test("an arcade-provenanced row is filled when the call passes arcade", async () => { + const id = await server(null, "arcade", () => "arcade://Gmail"); + await backfillBrokeredLogos(database, "arcade", { + listApps: async () => [app("Gmail")], + }); + expect((await row(id))?.logo).toBe("https://example.com/logo.svg"); +}); + +test("the same arcade row is left untouched when the call passes composio", async () => { + const id = await server(null, "arcade", () => "arcade://Gmail"); + await backfillBrokeredLogos(database, "composio", { + listApps: async () => [app("Gmail")], + }); + expect((await row(id))?.logo).toBeNull(); +}); From 01740c023f852b3597a10e4330b6b85df7024522 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:31:29 +0200 Subject: [PATCH 022/232] feat(arcade): refuse activating a provider while the other still holds brokered rows Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/broker.ts | 23 +++++++++++++++ server/tests/provider-selection.test.ts | 38 ++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index fd122d23c..1300caaef 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -785,3 +785,26 @@ export function activeProvider(c: { if (named !== undefined) return configured.includes(named) ? named : null; return configured.length === 1 ? configured[0] : null; } + +/** + * Activating a provider is refused while the OTHER provider still holds `brokered_connections` rows. + * + * KEYED ON ROWS, NOT ON KEYS, AND THAT IS THE WHOLE POINT. Both keys set is a legal and expected + * state, because ending an account at the old vendor needs its key, so a deployment holding both is + * never misconfigured. What a deployment must not be able to do is switch by editing its environment: + * `DELETE /servers/:id/connection` revokes at the vendor before it deletes anything local, and a + * deployment that swapped keys would hold rows for accounts it could no longer end. + * + * TAKES A COUNT, NOT A DATABASE. The caller counts the rows belonging to the provider that is not + * `candidate`; this reads no key, never calls {@link activeProvider} and imports no vendor package. + */ +export function assertActivatable( + candidate: BrokerProvider, + otherProviderRows: number, +): void { + if (otherProviderRows <= 0) return; + const other = candidate === "arcade" ? "Composio" : "Arcade"; + throw new BrokerRefusalError( + `${candidate === "arcade" ? "Arcade" : "Composio"} cannot be activated while ${otherProviderRows} brokered connection${otherProviderRows === 1 ? "" : "s"} still belong${otherProviderRows === 1 ? "s" : ""} to ${other}. Keep both API keys set, and end those connections through the ${other} sunset flow first; switching providers by changing the environment would leave accounts nobody can revoke.`, + ); +} diff --git a/server/tests/provider-selection.test.ts b/server/tests/provider-selection.test.ts index a7d138407..30512b71d 100644 --- a/server/tests/provider-selection.test.ts +++ b/server/tests/provider-selection.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { activeProvider } from "../src/plugins/broker"; +import { + activeProvider, + assertActivatable, + BrokerRefusalError, + brokerSentence, +} from "../src/plugins/broker"; /** * Which broker is active, decided from configuration alone: no network, no database. @@ -42,3 +47,34 @@ describe("activeProvider", () => { ).toBe(null); }); }); + +/** + * The activation refusal keys on ROWS, never on keys: no database, no key, no vendor package. + */ +describe("assertActivatable", () => { + test("rows for the other provider refuse activation, naming the sunset flow", () => { + let caught: unknown; + try { + assertActivatable("arcade", 3); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(BrokerRefusalError); + const sentence = brokerSentence(caught); + expect(sentence).toContain("sunset"); + expect(sentence).toContain("Composio"); + }); + + test("zero rows for the other provider lets activation proceed", () => { + expect(() => assertActivatable("arcade", 0)).not.toThrow(); + expect(() => assertActivatable("composio", 0)).not.toThrow(); + }); + + test("both keys being set, on its own, does not trigger the refusal", () => { + const keys = { composioApiKey: "c", arcadeApiKey: "a" }; + expect(activeProvider({ ...keys, brokerProvider: "arcade" })).toBe( + "arcade", + ); + expect(() => assertActivatable("arcade", 0)).not.toThrow(); + }); +}); From 90a4d08adbb04947053ac74fd46f34c40c6837af Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:31:46 +0200 Subject: [PATCH 023/232] feat(arcade): list a toolkit's tools through the injected client, by short page, with scopes and no identity Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade.ts | 139 ++++++++++++++++++++++++++++ server/tests/arcade-listing.test.ts | 101 ++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 server/tests/arcade-listing.test.ts diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index f0d88e46f..52126866b 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -5,6 +5,7 @@ */ import { flagOf } from "./broker"; +import type { ListedTool } from "./mcp"; /** * Whether listing tools needs somebody's credential. It does not: the listing uses the deployment's @@ -63,3 +64,141 @@ export function classify(metadata: unknown): { if (readOnly) return { effect: "read", destructive: false }; return { effect: "write", destructive: false }; } + +/** + * What this transport needs of Arcade's client, and nothing more: `./arcade-adapter` maps the + * vendor's SDK onto it, so this file imports no vendor package. Mirrors `ComposioActions`. + * + * `listTools` carries no `user_id` by construction: the query type has no such field. + */ +export type ArcadeActions = { + listTools(q: { + toolkit: string; + limit: number; + offset: number; + }): Promise<{ items: unknown[]; total_count?: number }>; + execute(r: { + tool_name: string; + input: Record; + user_id: string; + }): Promise; +}; + +let installed: ArcadeActions | null = null; + +/** Installs (or, with `null`, removes) the Arcade client. Null is also the unconfigured state. */ +export function useArcadeClient(client: ArcadeActions | null): void { + installed = client; +} + +export type ArcadeListedTool = ListedTool & { scopes: string[] }; + +const PAGE_SIZE = 100; +/** A runaway guard, not a bound: 1000 pages of 100 is far past any one toolkit. */ +const MAX_PAGES = 1000; + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * The OAuth scopes an action needs: `requirements.authorization.oauth2.scopes`. + * + * NEVER `requirements.met`. The two sit in the same object and behave oppositely: `met` is a + * per-person claim that answers TRUE FOR EVERYBODY when asked without a `user_id` (and this + * listing sends none), while `scopes` is a static property of the action that carries no identity. + * Reading `met` here would record every person as connected to everything, and nothing else would + * go red: the gate would pass, the call would be made, and the first sign would be somebody + * else's mailbox. + */ +function scopesOf(requirements: unknown): string[] { + const scopes = record( + record(record(requirements)?.authorization)?.oauth2, + )?.scopes; + return Array.isArray(scopes) + ? scopes.filter((s): s is string => typeof s === "string") + : []; +} + +function inputSchemaOf(input: unknown): Record { + const parameters = record(input)?.parameters; + const properties: Record = {}; + const required: string[] = []; + for (const raw of Array.isArray(parameters) ? parameters : []) { + const parameter = record(raw); + if (!parameter || typeof parameter.name !== "string") continue; + const valType = record(parameter.value_schema)?.val_type; + properties[parameter.name] = { + type: valType === "json" ? "object" : (valType ?? "string"), + description: + typeof parameter.description === "string" ? parameter.description : "", + }; + if (parameter.required === true) required.push(parameter.name); + } + return { type: "object", properties, required }; +} + +/** + * Every action of one toolkit, read to the end. + * + * NO `user_id` IS SENT. A listing is not somebody's: it is what this deployment offers everybody. + * + * PAGINATION STOPS ON A SHORT PAGE, never on `total_count`. That field is optional; an earlier draft + * fell back to `?? tools.length` and so exited after one page. It is used only as a cross-check. + */ +export async function listTools(connection: { + url: string; +}): Promise { + const toolkit = toolkitOf(connection.url); + if (!toolkit) { + throw new Error( + `${connection.url} does not name an Arcade toolkit, so nothing was asked what it offers. The url has to be arcade:// followed by a toolkit name; correct it on the Plugins page.`, + ); + } + if (!installed) { + throw new Error( + `Arcade is not configured for this deployment, so nothing could be asked what ${toolkit} offers. The actions already recorded for this app are kept rather than cleared.`, + ); + } + + const raw: unknown[] = []; + let total: number | undefined; + for (let page = 0; page < MAX_PAGES; page++) { + const answer = await installed.listTools({ + toolkit, + limit: PAGE_SIZE, + offset: raw.length, + }); + const items = (answer as { items?: unknown } | null)?.items; + if (!Array.isArray(items)) { + throw new Error( + `Arcade did not answer with an action list for ${toolkit}. Nothing was refreshed and the actions already recorded for this app are kept.`, + ); + } + raw.push(...items); + if (typeof answer.total_count === "number") total = answer.total_count; + if (items.length < PAGE_SIZE) break; + } + if (total !== undefined && raw.length < total) { + throw new Error( + `Arcade listed ${raw.length} of ${total} actions for ${toolkit}, so the listing is incomplete. Nothing was refreshed and the actions already recorded for this app are kept.`, + ); + } + + return raw.flatMap((item) => { + const tool = record(item); + if (!tool || typeof tool.qualified_name !== "string") return []; + return [ + { + name: tool.qualified_name, + description: + typeof tool.description === "string" ? tool.description : "", + inputSchema: inputSchemaOf(tool.input), + ...classify(tool.metadata), + scopes: scopesOf(tool.requirements), + }, + ]; + }); +} diff --git a/server/tests/arcade-listing.test.ts b/server/tests/arcade-listing.test.ts new file mode 100644 index 000000000..36d0c2c75 --- /dev/null +++ b/server/tests/arcade-listing.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { listTools, useArcadeClient } from "../src/plugins/arcade"; + +afterEach(() => useArcadeClient(null)); + +type Query = { toolkit: string; limit: number; offset: number }; + +function pagesOf(total: number) { + return async ({ offset, limit }: { offset: number; limit: number }) => { + const items = Array.from( + { length: Math.max(0, Math.min(limit, total - offset)) }, + (_, i) => ({ + qualified_name: `GithubApi.Tool${offset + i}`, + description: "", + input: { parameters: [] }, + }), + ); + return { items }; // NO total_count + }; +} + +/** Records every query the client was sent, so assertions are on what went OUT. */ +function recording(answer: (q: Query) => Promise<{ items: unknown[] }>) { + const sent: Record[] = []; + useArcadeClient({ + listTools: async (q) => { + sent.push({ ...q }); + return answer(q); + }, + execute: async () => { + throw new Error("listing must not execute anything"); + }, + }); + return sent; +} + +describe("listTools scopes", () => { + /* + * `requirements.authorization.oauth2.scopes` is READ. `requirements.met` is NEVER read. They sit in + * the same object and behave oppositely: `met` is a per-person claim that answers true for + * everybody when asked without a `user_id`; `scopes` is a static property of the action. + * Every tool below says `met: true`, and nothing about the answer may carry it. + */ + test("reads oauth2.scopes and never requirements.met", async () => { + const behavior = { read_only: true, destructive: false }; + recording(async () => ({ + items: [ + { + qualified_name: "Gmail.ArchiveEmail", + description: "Archive", + input: { parameters: [] }, + requirements: { + met: true, + authorization: { + status: "active", + oauth2: { + scopes: ["https://www.googleapis.com/auth/gmail.modify"], + }, + }, + }, + metadata: { behavior }, + }, + { + qualified_name: "Gmail.Plain", + description: "", + input: { parameters: [] }, + requirements: { met: true }, + metadata: { behavior }, + }, + ], + })); + const tools = await listTools({ url: "arcade://Mixed" }); + expect(tools[0].scopes).toEqual([ + "https://www.googleapis.com/auth/gmail.modify", + ]); + expect(tools[1].scopes).toEqual([]); + expect(JSON.stringify(tools)).not.toContain('"met"'); + }); +}); + +describe("listTools pagination", () => { + test("stops on a short page, with no total_count", async () => { + const sent = recording(async (q) => pagesOf(250)(q)); + const tools = await listTools({ url: "arcade://GithubApi" }); + expect(tools).toHaveLength(250); + expect(new Set(tools.map((t) => t.name)).size).toBe(250); + expect(sent.map((q) => q.offset)).toEqual([0, 100, 200]); + }); +}); + +describe("listTools identity", () => { + test("no request carried a user_id", async () => { + const sent = recording(async (q) => pagesOf(250)(q)); + await listTools({ url: "arcade://GithubApi" }); + expect(sent.length).toBeGreaterThan(0); + for (const q of sent) { + expect("user_id" in q).toBe(false); + expect(q.toolkit).toBe("GithubApi"); + } + }); +}); From 91b343ca3d5307caf28a0215197b6803831cfefd Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:33:12 +0200 Subject: [PATCH 024/232] refactor(arcade): dispatch the toolkit parse on the provider in store.ts Export toolkitOfFor(provider, url) from access.ts, reading the module-private TOOLKIT_OF, and route all eleven store.ts call sites through it keyed on the resolved brokerProvider. store.ts no longer imports composio's toolkitOf and has no dispatch of its own. Never try both parsers: a null from the wrong parser reads as "names no app" and every symptom is silence. addBrokeredApp's `composio://` check (store.ts) is converted here too. Micro-task 3.2 re-parameterises that function and must keep this dispatch. Delete the byte-identical TOOLKIT_OF in logos.ts; backfillBrokeredLogos calls toolkitOfFor(provider, row.url). Annotate the arcade-untouched logo test with the refactor it alone catches. Add store-toolkit-dispatch.test.ts. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/access.ts | 12 +++ server/src/plugins/logos.ts | 16 +--- server/src/plugins/store.ts | 34 ++++--- server/tests/plugin-logos.integration.test.ts | 1 + server/tests/store-toolkit-dispatch.test.ts | 90 +++++++++++++++++++ 5 files changed, 126 insertions(+), 27 deletions(-) create mode 100644 server/tests/store-toolkit-dispatch.test.ts diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 39eb96baa..61b96994c 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -267,6 +267,18 @@ const TOOLKIT_OF: Record string | null> = { arcade: arcadeToolkitOf, }; +/** + * {@link TOOLKIT_OF}, reachable. A function so a caller cannot index the table with a widened + * string, and so the table stays private to the one module that argues for it: a second dispatch + * anywhere else is a second place for a provider to be missed. + */ +export function toolkitOfFor( + provider: BrokerProvider, + url: string, +): string | null { + return TOOLKIT_OF[provider](url); +} + /** * The transport kind a broker is dialled over, which is the provider's own name. * diff --git a/server/src/plugins/logos.ts b/server/src/plugins/logos.ts index 56b9ca5a1..604d686e7 100644 --- a/server/src/plugins/logos.ts +++ b/server/src/plugins/logos.ts @@ -1,19 +1,8 @@ import { and, eq, isNull } from "drizzle-orm"; import type { Database } from "../db/client"; import { mcpServers } from "../db/schema"; -import type { BrokerProvider } from "./access"; -import { toolkitOf as arcadeToolkitOf } from "./arcade"; +import { type BrokerProvider, toolkitOfFor } from "./access"; import type { ComposioBroker } from "./broker"; -import { toolkitOf as composioToolkitOf } from "./composio"; - -/** - * The url parser that matches the provider a row is provenanced at. The two schemes name apps under - * different rules, so a row is read by its own broker's parser or not at all. - */ -const TOOLKIT_OF: Record string | null> = { - composio: composioToolkitOf, - arcade: arcadeToolkitOf, -}; /** Fill older app records without changing their connections, schemes, or grants. */ export async function backfillBrokeredLogos( @@ -21,7 +10,6 @@ export async function backfillBrokeredLogos( provider: BrokerProvider, broker: Pick, ) { - const toolkitOf = TOOLKIT_OF[provider]; const missing = await database .select({ id: mcpServers.id, url: mcpServers.url }) .from(mcpServers) @@ -32,7 +20,7 @@ export async function backfillBrokeredLogos( (await broker.listApps()).map((app) => [app.slug, app.logo]), ); for (const row of missing) { - const slug = toolkitOf(row.url); + const slug = toolkitOfFor(provider, row.url); const logo = slug ? logos.get(slug) : null; if (!logo) continue; await database diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index f5ace7425..15b4dd9c8 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -49,6 +49,7 @@ import { type BrokerProvider, type ServerAccess, ServerUnresolvableError, + toolkitOfFor, } from "./access"; import { type BrokerConnection, @@ -72,7 +73,6 @@ import { import { asksForArguments, askAction as composioAskAction, - toolkitOf, VERSION_ARG, } from "./composio"; import { inspectToolArguments } from "./content-governance"; @@ -1468,7 +1468,9 @@ export function createPluginStore(options: PluginStoreOptions) { * so only a removal racing this read gets here, and it is refused as the missing row is above. */ const toolkit = - dialled.provenance === brokerProvider ? toolkitOf(dialled.url) : null; + dialled.provenance === brokerProvider + ? toolkitOfFor(brokerProvider, dialled.url) + : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; if (toolkit && !app) return { @@ -2643,7 +2645,9 @@ export function createPluginStore(options: PluginStoreOptions) { * than the one that was dialled. Null for every row that is not a brokered app. */ const toolkit = - row.provenance === brokerProvider ? toolkitOf(row.url) : null; + row.provenance === brokerProvider + ? toolkitOfFor(brokerProvider, row.url) + : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; const access = accessFor( app @@ -3153,7 +3157,7 @@ export function createPluginStore(options: PluginStoreOptions) { * would then be spent at; this one composes `composio://` itself, and a brokered row is * never dialled at a host at all — the transport reads the app off that url and asks Composio, * over the deployment's own key. So the only thing left to check about the url is that it says - * what this call meant, and {@link toolkitOf} is what checks it: the slug goes in, the url comes + * what this call meant, and {@link toolkitOfFor} is what checks it: the slug goes in, the url comes * back out through the very function `accessFor` will read it with, and a slug those two * disagree about is refused rather than stored. A pattern written here instead would be a second * opinion about the shape of an app name, and the reading that decides which app a call runs @@ -3195,7 +3199,7 @@ export function createPluginStore(options: PluginStoreOptions) { if (!broker) throw new BrokerUnconfiguredError(); const url = `composio://${input.slug}`; - if (toolkitOf(url) !== input.slug) { + if (toolkitOfFor(brokerProvider, url) !== input.slug) { throw new CustomServerRefusedError( `${input.slug} is not a name a Composio app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, ); @@ -4372,7 +4376,9 @@ export function createPluginStore(options: PluginStoreOptions) { * ONE EXTRA READ FOR THE WHOLE LIST, and none where the deployment has enabled no apps. */ const brokeredApps = await brokeredAppRowsAt( - rows.filter((row) => toolkitOf(row.url) !== null).map((row) => row.url), + rows + .filter((row) => toolkitOfFor(brokerProvider, row.url) !== null) + .map((row) => row.url), ); /* @@ -4396,7 +4402,7 @@ export function createPluginStore(options: PluginStoreOptions) { return rows.map((row) => { const entry = catalogueEntry(row.id); - const toolkit = toolkitOf(row.url); + const toolkit = toolkitOfFor(brokerProvider, row.url); return { id: row.id, title: row.title, @@ -4519,7 +4525,7 @@ export function createPluginStore(options: PluginStoreOptions) { .where(eq(mcpServers.id, serverId)) .limit(1); if (!row) return undefined; - const toolkit = toolkitOf(row.url); + const toolkit = toolkitOfFor(brokerProvider, row.url); if (!toolkit) return { ...row, accountMode: null }; const app = await brokeredAppRow(toolkit); return { @@ -4559,7 +4565,7 @@ export function createPluginStore(options: PluginStoreOptions) { .from(mcpServers) .where(eq(mcpServers.id, serverId)) .limit(1); - const toolkit = row ? toolkitOf(row.url) : null; + const toolkit = row ? toolkitOfFor(brokerProvider, row.url) : null; if (!toolkit) return null; const held = await database .select({ @@ -4608,7 +4614,9 @@ export function createPluginStore(options: PluginStoreOptions) { .from(mcpServers) .where(eq(mcpServers.id, serverId)) .limit(1); - const toolkit = dialled ? toolkitOf(dialled.url) : null; + const toolkit = dialled + ? toolkitOfFor(brokerProvider, dialled.url) + : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; await database .update(mcpServers) @@ -5448,7 +5456,7 @@ export function createPluginStore(options: PluginStoreOptions) { .from(mcpServers) .where(eq(mcpServers.id, serverId)) .limit(1); - const toolkit = server ? toolkitOf(server.url) : null; + const toolkit = server ? toolkitOfFor(brokerProvider, server.url) : null; if (!toolkit) return null; const [row] = await database @@ -5532,7 +5540,7 @@ export function createPluginStore(options: PluginStoreOptions) { .orderBy(asc(mcpServers.id)); const answering = await brokeredAppRowsAt( marked - .filter((row) => toolkitOf(row.url) !== null) + .filter((row) => toolkitOfFor(brokerProvider, row.url) !== null) .map((row) => row.url), ); const shared = marked.filter( @@ -5541,7 +5549,7 @@ export function createPluginStore(options: PluginStoreOptions) { return await Promise.all( shared.map(async (server) => { - const toolkit = toolkitOf(server.url); + const toolkit = toolkitOfFor(brokerProvider, server.url); const [connection, kind] = await Promise.all([ this.deploymentConnectionFor(server.id), toolkit ? brokeredAppKind(toolkit) : null, diff --git a/server/tests/plugin-logos.integration.test.ts b/server/tests/plugin-logos.integration.test.ts index ad9b137ee..9d99bb0cd 100644 --- a/server/tests/plugin-logos.integration.test.ts +++ b/server/tests/plugin-logos.integration.test.ts @@ -88,6 +88,7 @@ test("an arcade-provenanced row is filled when the call passes arcade", async () expect((await row(id))?.logo).toBe("https://example.com/logo.svg"); }); +// This is what fails if the backfill is ever made provider-agnostic by widening the slice and trying both parsers; see `access.ts`'s `TOOLKIT_OF`. test("the same arcade row is left untouched when the call passes composio", async () => { const id = await server(null, "arcade", () => "arcade://Gmail"); await backfillBrokeredLogos(database, "composio", { diff --git a/server/tests/store-toolkit-dispatch.test.ts b/server/tests/store-toolkit-dispatch.test.ts new file mode 100644 index 000000000..ad37ef1c6 --- /dev/null +++ b/server/tests/store-toolkit-dispatch.test.ts @@ -0,0 +1,90 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { brokeredConnections, mcpServers } from "../src/db/schema"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/* + * `store.ts` reads the app out of a row's url through the provider's own parser, dispatched in + * `access.ts` and keyed on the store's `brokerProvider`. These drive `accountsOn`, which reads the + * toolkit off a row's url and answers nothing for a url that names no app at this store's broker. + */ +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const serverIds: string[] = []; +const connectionApps: string[] = []; + +afterEach(async () => { + for (const id of serverIds.splice(0)) + await database.delete(mcpServers).where(eq(mcpServers.id, id)); + for (const app of connectionApps.splice(0)) + await database + .delete(brokeredConnections) + .where(eq(brokeredConnections.app, app)); +}); +afterAll(async () => { + await database.$client.close(); +}); + +const broker = {} as ConnectedAppBroker; + +function storeFor(brokerProvider: BrokerProvider) { + return createPluginStore({ + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, + brokerProvider, + deploymentId: `acme-${suite}`, + }); +} + +async function seed(provenance: BrokerProvider, url: string, app: string) { + const id = `dispatch-${suite}-${randomUUID().slice(0, 6)}`; + await database.insert(mcpServers).values({ + id, + title: "Example", + vendor: provenance, + url, + provenance, + authScheme: "OAUTH2", + }); + serverIds.push(id); + await database.insert(brokeredConnections).values({ + provider: provenance, + app, + holder: "person", + userId: `user-${suite}`, + vendorUserId: `vendor-${suite}`, + }); + connectionApps.push(app); + return id; +} + +describe("the toolkit a row names is read by the store's own provider", () => { + test("an arcade://Gmail row resolves to Gmail with case intact under arcade", async () => { + const id = await seed("arcade", `arcade://Gmail`, `Gmail`); + const answer = await storeFor("arcade").accountsOn(id); + expect(answer?.toolkit).toBe("Gmail"); + expect(answer?.accounts).toHaveLength(1); + }); + + test("the same row under composio names no app and is refused, not mis-parsed", async () => { + const id = await seed("arcade", `arcade://Gmail`, `Gmail`); + expect(await storeFor("composio").accountsOn(id)).toBeNull(); + }); + + test("a composio://gmail row under composio is unchanged", async () => { + const id = await seed("composio", `composio://gmail`, `gmail`); + const answer = await storeFor("composio").accountsOn(id); + expect(answer?.toolkit).toBe("gmail"); + expect(answer?.accounts).toHaveLength(1); + }); +}); From e6436060ce4676b9a3472201705dd424d4d1c208 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:39:56 +0200 Subject: [PATCH 025/232] refactor(arcade): make the store's broker provider required and pass the active one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PluginStoreOptions.brokerProvider` was optional with a `composio` fallback inside `createPluginStore`, and a TODO asking the next person to remember to pass it. The compiler now asks instead. The fallback had no symptom. An Arcade deployment built by forgetting the field read and wrote the COMPOSIO slice of `brokered_connections`: its rows came back through a composio filter and the thing appeared to work, while spec D3's "the other provider holds rows" refusal never fired — nothing was ever filed under the other name — and the offboarding sweep read a slice that was not this deployment's. Nothing went red. That reasoning now lives in the field's docblock, beside the `broker?` field whose absence IS a legitimate state. There is one `??` left in the system, at `server/src/index.ts`, the only production call site: `activeProvider(config) ?? "composio"`. `activeProvider` answers `null` only when this deployment has no broker at all, in which case no broker reaches the store, no brokered row exists, and the value merely names which empty slice an unreachable query would read. It is the single place where "which provider" has no answer, and it is visible at the deployment boundary rather than asserted inside a constructor. No other call site defaults it: the 38 test sites pass `"composio"` explicitly, and no defaulting helper was introduced — one would recreate this exact hidden default a layer further out, at precisely the point where the Arcade suites must pass `"arcade"`. The type pin lives in `server/tests/store-provider-required.test.ts`, and that ONE file is named in `server/tsconfig.json`'s `include`. `server/tests` as a whole is outside the program, so a `@ts-expect-error` written there would be read by nothing and assert nothing while looking like an assertion. Naming the file rather than the directory is the line the tsconfig already draws for `composio-smoke.ts`; type-checking the whole suite remains its own sweep. The pin was verified red before the change (TS2578, unused directive) and is green after, and it fails in both directions if the field ever goes optional again. No Composio behaviour changes: every site that previously defaulted to `composio` now says it. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/index.ts | 9 + server/src/plugins/store.ts | 31 ++-- server/tests/account-mode.integration.test.ts | 1 + .../tests/composio-connection-kinds.test.ts | 1 + server/tests/composio-connections.test.ts | 1 + .../demonstrations-store.integration.test.ts | 1 + .../mcp-grant-removal.integration.test.ts | 1 + server/tests/memory-store.integration.test.ts | 1 + ...gin-credential-binding.integration.test.ts | 1 + ...in-credential-rotation.integration.test.ts | 1 + ...gin-curated-credential.integration.test.ts | 1 + .../tests/plugin-routes.integration.test.ts | 1 + server/tests/plugin-store.integration.test.ts | 12 ++ ...plugin-user-credential.integration.test.ts | 1 + .../tests/proactive-store.integration.test.ts | 1 + .../remote-bot-handoff.integration.test.ts | 1 + .../server-side-tools.integration.test.ts | 1 + server/tests/shared-brokered-routes.test.ts | 1 + .../shared-brokered-store.integration.test.ts | 6 + .../shared-gated-mode.integration.test.ts | 1 + .../tests/shared-wiring.integration.test.ts | 1 + .../tests/skill-ownership.integration.test.ts | 1 + server/tests/skill-tools.integration.test.ts | 1 + ...skill-uninstall-grants.integration.test.ts | 1 + server/tests/store-provider-required.test.ts | 164 ++++++++++++++++++ server/tsconfig.json | 14 +- 26 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 server/tests/store-provider-required.test.ts diff --git a/server/src/index.ts b/server/src/index.ts index efa5d1b5c..e29e334d9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -192,6 +192,7 @@ import { createPasswordStore } from "./passwords/store"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { createAccountModeSwitch } from "./plugins/account-mode"; +import { activeProvider } from "./plugins/broker"; import { useRoutineTools } from "./plugins/builtin-routines"; import { useComposioClient } from "./plugins/composio"; import { createComposioClient } from "./plugins/composio-adapter"; @@ -765,6 +766,14 @@ const pluginStore = createPluginStore({ * attempted. */ broker: composio?.broker, + /* + * `activeProvider` answers `null` only when this deployment has no broker at all: no key is set, + * so no broker reaches the store, no brokered row exists, and the value merely names which empty + * slice an unreachable query would read. That is why the one `??` in the system is HERE and only + * here — the single place where "which provider" has no answer, visible at the deployment boundary + * instead of asserted forty times over inside a constructor. + */ + brokerProvider: activeProvider(config) ?? "composio", }); /* diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 15b4dd9c8..9fc0c5bb3 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1202,17 +1202,21 @@ export type PluginStoreOptions = { * offboarding sweep swept the wrong table slice. That is the failure this field exists to make * impossible to reach by accident. * - * OPTIONAL ONLY BECAUSE NOTHING PASSES IT YET, AND THE FALLBACK IS THE ONLY VENDOR ANY DEPLOYMENT - * RUNS TODAY. `activeProvider` in `./broker` is what decides this from configuration, and wiring - * it through the routes that build a store is a later change; until then every store is a Composio - * store and `composio` is what it always wrote. The fallback is HERE, at the deployment boundary - * where the answer is knowable, and nowhere else: no function below defaults it, because a default - * deeper in is a second answer to this question that nothing holds equal to this one. + * REQUIRED, UNLIKE `broker` ABOVE, BECAUSE FORGETTING IT HAS NO SYMPTOM. An absent `broker` says + * one true thing — this deployment has not configured one — and every path that needs it raises. + * An absent provider would have had to be guessed, and the only guess available was `composio`: + * an Arcade deployment built by forgetting this field would then read and write the COMPOSIO slice + * of `brokered_connections`, hand its own rows back through a composio filter, and APPEAR TO WORK. + * Spec D3's "the other provider holds rows" refusal would never fire, because nothing would ever + * be filed under the other name, and the offboarding sweep would sweep a slice that is not this + * deployment's. Nothing goes red. There is no error to raise later, so the compiler asks now. * - * TODO: pass `activeProvider(config)` at every `createPluginStore` call and make this required, so - * that an Arcade deployment cannot be built by forgetting a field. + * SO THERE IS NO DEFAULT ANYWHERE — not here, not in {@link createPluginStore}, not in a test + * helper. A function that cannot reach the provider takes it as a parameter; a second answer to + * "which provider" is a second thing that can be wrong while agreeing with nothing. `activeProvider` + * in `./broker` decides it from configuration at the one deployment boundary that knows. */ - brokerProvider?: BrokerProvider; + brokerProvider: BrokerProvider; /** Where the vendor sends people back; needed to (re)register a dynamic client. */ redirectUri?: string; /** @@ -1260,12 +1264,11 @@ export function createPluginStore(options: PluginStoreOptions) { // to, and a deployment with no Composio key is supposed to have no broker. See `./broker`. const broker = options.broker; /* - * The broker this store is for, resolved once so that every read of `brokered_connections`, every - * `provenance` test and the connection writer below are reading ONE value rather than each - * re-deciding. See {@link PluginStoreOptions.brokerProvider} for why the fallback is here and only - * here. + * The broker this store is for: every read of `brokered_connections`, every `provenance` test and + * the connection writer below read THIS rather than each re-deciding. Taken as given, never + * defaulted — see {@link PluginStoreOptions.brokerProvider} for why there is no fallback here. */ - const brokerProvider: BrokerProvider = options.brokerProvider ?? "composio"; + const brokerProvider: BrokerProvider = options.brokerProvider; /* * One exchange at a time per (server, person). A rotating vendor invalidates the refresh diff --git a/server/tests/account-mode.integration.test.ts b/server/tests/account-mode.integration.test.ts index 8caa2a883..230287d21 100644 --- a/server/tests/account-mode.integration.test.ts +++ b/server/tests/account-mode.integration.test.ts @@ -50,6 +50,7 @@ const broker = { } satisfies ConnectedAppBroker; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: {} as never, diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index 184642e26..39513ae53 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -709,6 +709,7 @@ const broker: ConnectedAppBroker = { }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), broker, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 42f626ab8..08b33863e 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -776,6 +776,7 @@ const broker: ComposioBroker = { }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore, broker, diff --git a/server/tests/demonstrations-store.integration.test.ts b/server/tests/demonstrations-store.integration.test.ts index 863c9da5c..2579167b8 100644 --- a/server/tests/demonstrations-store.integration.test.ts +++ b/server/tests/demonstrations-store.integration.test.ts @@ -32,6 +32,7 @@ const other = `${prefix}-other`; const bot = `${prefix}-bot`; const skillSlug = `demo-${randomUUID().slice(0, 8)}`; const plugins = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: createCredentialStore(database), diff --git a/server/tests/mcp-grant-removal.integration.test.ts b/server/tests/mcp-grant-removal.integration.test.ts index 70b4f0ad8..0d6b67efc 100644 --- a/server/tests/mcp-grant-removal.integration.test.ts +++ b/server/tests/mcp-grant-removal.integration.test.ts @@ -47,6 +47,7 @@ const auditStore = { }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore, credentials: { diff --git a/server/tests/memory-store.integration.test.ts b/server/tests/memory-store.integration.test.ts index de733595b..7e9383676 100644 --- a/server/tests/memory-store.integration.test.ts +++ b/server/tests/memory-store.integration.test.ts @@ -27,6 +27,7 @@ let vendorText = let vendorError = false; const calls: { actorId?: string; botId?: string }[] = []; const plugins = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: createCredentialStore(database), diff --git a/server/tests/plugin-credential-binding.integration.test.ts b/server/tests/plugin-credential-binding.integration.test.ts index 68faea352..5d5eef8f5 100644 --- a/server/tests/plugin-credential-binding.integration.test.ts +++ b/server/tests/plugin-credential-binding.integration.test.ts @@ -49,6 +49,7 @@ const LEGITIMATE_URL = "https://legit.vendor.example/mcp"; const CHOSEN_URL = "https://collector.attacker.example/mcp"; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { diff --git a/server/tests/plugin-credential-rotation.integration.test.ts b/server/tests/plugin-credential-rotation.integration.test.ts index a0751e631..c6a5209e2 100644 --- a/server/tests/plugin-credential-rotation.integration.test.ts +++ b/server/tests/plugin-credential-rotation.integration.test.ts @@ -33,6 +33,7 @@ const serverId = "google-drive"; const personId = `plugin_rotation_person_${suite}`; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: createCredentialStore(database), diff --git a/server/tests/plugin-curated-credential.integration.test.ts b/server/tests/plugin-curated-credential.integration.test.ts index 0ef97ac5b..44ea8a58a 100644 --- a/server/tests/plugin-curated-credential.integration.test.ts +++ b/server/tests/plugin-curated-credential.integration.test.ts @@ -31,6 +31,7 @@ import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { diff --git a/server/tests/plugin-routes.integration.test.ts b/server/tests/plugin-routes.integration.test.ts index 7f3c421d8..f4b1ae4e6 100644 --- a/server/tests/plugin-routes.integration.test.ts +++ b/server/tests/plugin-routes.integration.test.ts @@ -26,6 +26,7 @@ import { testEnvironment } from "./support/environment"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1ba31ef34..1f988b5b1 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -159,6 +159,7 @@ const credentialsStub = { }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: credentialsStub, @@ -1331,6 +1332,7 @@ describe("refresh token rotation", () => { }; const rotationStore = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: vault, @@ -1361,6 +1363,7 @@ describe("refresh token rotation", () => { */ function replica(exchange: (refreshToken: string) => Promise) { return createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: vault, @@ -2411,6 +2414,7 @@ describe("a dynamic client the vendor has evicted", () => { }; const dynamicStore = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: vault, @@ -2432,6 +2436,7 @@ describe("a dynamic client the vendor has evicted", () => { * complete a consent flow. */ const storeWithNoRedirect = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: vault, @@ -2884,6 +2889,7 @@ describe("a dynamic client the vendor has evicted", () => { * a stub asserting the value the real code is assumed to produce. */ const storeWithRealRegistration = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: vault, @@ -4060,6 +4066,7 @@ async function freshStore(options: { broker?: ComposioBroker } = {}) { }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore, credentials: credentialsStub, @@ -6115,6 +6122,7 @@ describe("a listing this database would not have taken", () => { }); const failing = createPluginStore({ + brokerProvider: "composio", database: refusing, auditStore: { insert: async () => {} }, credentials: credentialsStub, @@ -6250,6 +6258,7 @@ describe("a query of this deployment's own that failed", () => { }, }); const failing = createPluginStore({ + brokerProvider: "composio", database: refusing, auditStore: { insert: async () => {} }, credentials: credentialsStub, @@ -6380,6 +6389,7 @@ describe("a query of this deployment's own that failed", () => { * throw is classify it, which is the property. */ const failing = createPluginStore({ + brokerProvider: "composio", database, auditStore: { insert: async (event) => { @@ -6523,6 +6533,7 @@ test("an app with nothing recorded against it can be refreshed to no actions at test("an audit write that fails is not recorded as the vendor misbehaving", async () => { const database = await freshDatabase(); const failing = createPluginStore({ + brokerProvider: "composio", database, auditStore: { insert: async (event) => { @@ -7801,6 +7812,7 @@ describe("a vault read that fails on a query of this deployment's own", () => { * left it somewhere else cannot decide whether this call gets as far as the vault. */ const faultStore = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: createCredentialStore( diff --git a/server/tests/plugin-user-credential.integration.test.ts b/server/tests/plugin-user-credential.integration.test.ts index b425e64d8..a88c61c54 100644 --- a/server/tests/plugin-user-credential.integration.test.ts +++ b/server/tests/plugin-user-credential.integration.test.ts @@ -92,6 +92,7 @@ let toolWasAlreadyAdvertised = false; const credentialIds: string[] = []; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { diff --git a/server/tests/proactive-store.integration.test.ts b/server/tests/proactive-store.integration.test.ts index de3e36f8b..9018135e7 100644 --- a/server/tests/proactive-store.integration.test.ts +++ b/server/tests/proactive-store.integration.test.ts @@ -30,6 +30,7 @@ const server = `${prefix}-connector`; const ref = `${server}/list_issues`; const auditStore = createAuditStore(database); const plugins = createPluginStore({ + brokerProvider: "composio", database, auditStore, credentials: createCredentialStore(database), diff --git a/server/tests/remote-bot-handoff.integration.test.ts b/server/tests/remote-bot-handoff.integration.test.ts index 9116b5dd4..9487a58eb 100644 --- a/server/tests/remote-bot-handoff.integration.test.ts +++ b/server/tests/remote-bot-handoff.integration.test.ts @@ -48,6 +48,7 @@ const broker: ConnectedAppBroker = { const auditStore = createAuditStore(database); const store = createPluginStore({ + brokerProvider: "composio", database, auditStore, credentials: { diff --git a/server/tests/server-side-tools.integration.test.ts b/server/tests/server-side-tools.integration.test.ts index 9d55f437e..a47abf7f5 100644 --- a/server/tests/server-side-tools.integration.test.ts +++ b/server/tests/server-side-tools.integration.test.ts @@ -41,6 +41,7 @@ const ref = `${serverId}/${toolName}`; let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { readSecret: async () => null }, diff --git a/server/tests/shared-brokered-routes.test.ts b/server/tests/shared-brokered-routes.test.ts index ad74e3667..2acc78cb0 100644 --- a/server/tests/shared-brokered-routes.test.ts +++ b/server/tests/shared-brokered-routes.test.ts @@ -63,6 +63,7 @@ const broker: ConnectedAppBroker = { function appAs(role: "admin" | "user", id = `${role}-${suite}`) { const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: {} as never, diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 59d2e912e..c8413b30e 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -52,6 +52,7 @@ const credentialsStub = {} as never; function freshStore() { return createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: credentialsStub, @@ -234,6 +235,7 @@ describe("a shared app", () => { const events: { eventType: string; payload: Record }[] = []; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: { insert: async (event) => void events.push(event as never) }, credentials: credentialsStub, @@ -266,6 +268,7 @@ describe("a shared app", () => { test("with no audience gate configured, a shared call is refused rather than allowed", async () => { const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: credentialsStub, @@ -374,6 +377,7 @@ describe("a duplicate row at an app's url", () => { const events: { eventType: string; payload: Record }[] = []; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: { insert: async (event) => void events.push(event as never) }, credentials: credentialsStub, @@ -419,6 +423,7 @@ describe("a duplicate row at an app's url", () => { const events: { eventType: string; payload: Record }[] = []; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: { insert: async (event) => void events.push(event as never) }, credentials: credentialsStub, @@ -473,6 +478,7 @@ describe("a duplicate row at an app's url", () => { test("asks the approval gate under the app's id for a write granted through it", async () => { const gateSaw: { toolRef: string; scope: string }[] = []; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: credentialsStub, diff --git a/server/tests/shared-gated-mode.integration.test.ts b/server/tests/shared-gated-mode.integration.test.ts index 9bc2ac084..805ef6576 100644 --- a/server/tests/shared-gated-mode.integration.test.ts +++ b/server/tests/shared-gated-mode.integration.test.ts @@ -97,6 +97,7 @@ describe("connectionTokenFor's gatedMode guard", () => { }, }); const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: credentialsStub, diff --git a/server/tests/shared-wiring.integration.test.ts b/server/tests/shared-wiring.integration.test.ts index 52da8351c..a55e3a9d0 100644 --- a/server/tests/shared-wiring.integration.test.ts +++ b/server/tests/shared-wiring.integration.test.ts @@ -40,6 +40,7 @@ const broker: ConnectedAppBroker = { function build(options: { shared: boolean }) { const auditStore = createAuditStore(database); const pluginStore = createPluginStore({ + brokerProvider: "composio", database, auditStore, credentials: {} as never, diff --git a/server/tests/skill-ownership.integration.test.ts b/server/tests/skill-ownership.integration.test.ts index 273105315..871004d1b 100644 --- a/server/tests/skill-ownership.integration.test.ts +++ b/server/tests/skill-ownership.integration.test.ts @@ -23,6 +23,7 @@ const database = createDatabase(testDatabaseUrl(), TEST_POOL); const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { readSecret: async () => null }, diff --git a/server/tests/skill-tools.integration.test.ts b/server/tests/skill-tools.integration.test.ts index f50e74685..df09ba944 100644 --- a/server/tests/skill-tools.integration.test.ts +++ b/server/tests/skill-tools.integration.test.ts @@ -29,6 +29,7 @@ const database = createDatabase(testDatabaseUrl(), TEST_POOL); const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { readSecret: async () => null }, diff --git a/server/tests/skill-uninstall-grants.integration.test.ts b/server/tests/skill-uninstall-grants.integration.test.ts index 562f4fd23..53fc0c5bb 100644 --- a/server/tests/skill-uninstall-grants.integration.test.ts +++ b/server/tests/skill-uninstall-grants.integration.test.ts @@ -20,6 +20,7 @@ const database = createDatabase(testDatabaseUrl(), TEST_POOL); const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; const store = createPluginStore({ + brokerProvider: "composio", database, auditStore: createAuditStore(database), credentials: { readSecret: async () => null }, diff --git a/server/tests/store-provider-required.test.ts b/server/tests/store-provider-required.test.ts new file mode 100644 index 000000000..cc0af2af0 --- /dev/null +++ b/server/tests/store-provider-required.test.ts @@ -0,0 +1,164 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { brokeredConnections, mcpServers } from "../src/db/schema"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/* + * `brokerProvider` is REQUIRED on `PluginStoreOptions`, and this file is the pin for both halves of + * what that buys. + * + * THE TYPE HALF IS CHECKED BY `tsc`, NOT BY BUN. This one file is named in `server/tsconfig.json`'s + * `include` for exactly that reason: `server/tests` as a whole is outside the program, so a + * `@ts-expect-error` written here would otherwise be read by nothing and assert nothing while + * looking like it asserted something. Run `bun run typecheck` to run that half. + */ +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const serverIds: string[] = []; +const apps: string[] = []; + +afterEach(async () => { + for (const id of serverIds.splice(0)) + await database.delete(mcpServers).where(eq(mcpServers.id, id)); + for (const app of apps.splice(0)) + await database + .delete(brokeredConnections) + .where(eq(brokeredConnections.app, app)); +}); +afterAll(async () => { + await database.$client.close(); +}); + +const broker = {} as ConnectedAppBroker; + +const base = { + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, +}; + +function storeFor(brokerProvider: BrokerProvider) { + return createPluginStore({ ...base, brokerProvider }); +} + +/* + * THE TYPE PIN. Omitting `brokerProvider` must not compile. If the field ever goes back to being + * optional this line stops erroring and `tsc` fails on the unused directive instead, so the pin + * cannot rot into silence in either direction. + */ +// @ts-expect-error - brokerProvider is required: a store must name the broker it is for. +const _withoutProvider: unknown = () => createPluginStore(base); + +describe("the store reads and writes its own provider's slice", () => { + test("an arcade store writes arcade rows that only an arcade store reads back", async () => { + const app = `Gmail${suite}`; + const id = `required-${suite}-${randomUUID().slice(0, 6)}`; + await database.insert(mcpServers).values({ + id, + title: "Example", + vendor: "arcade", + url: `arcade://${app}`, + provenance: "arcade", + authScheme: "OAUTH2", + }); + serverIds.push(id); + apps.push(app); + + await storeFor("arcade").recordBrokeredConnection({ + toolkit: app, + account: { + holder: "person", + userId: `user-${suite}`, + vendorUserId: `vendor-${suite}`, + }, + verified: true, + probeAction: null, + }); + + const rows = await database + .select({ + provider: brokeredConnections.provider, + userId: brokeredConnections.userId, + }) + .from(brokeredConnections) + .where(eq(brokeredConnections.app, app)); + expect(rows).toHaveLength(1); + expect(rows[0]?.provider).toBe("arcade"); + + const seen = await storeFor("arcade").accountsOn(id); + expect(seen?.toolkit).toBe(app); + expect(seen?.accounts).toEqual([ + { + holder: "person", + userId: `user-${suite}`, + vendorUserId: `vendor-${suite}`, + }, + ]); + }); + + test("a composio store writes composio rows and does not see the arcade ones", async () => { + const app = `gmail${suite}`; + const id = `required-${suite}-${randomUUID().slice(0, 6)}`; + await database.insert(mcpServers).values({ + id, + title: "Example", + vendor: "composio", + url: `composio://${app}`, + provenance: "composio", + authScheme: "OAUTH2", + }); + serverIds.push(id); + apps.push(app); + + // An arcade row already filed under the same app name, which a composio store must not read. + await database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder: "person", + userId: `other-${suite}`, + vendorUserId: `othervendor-${suite}`, + }); + + await storeFor("composio").recordBrokeredConnection({ + toolkit: app, + account: { + holder: "person", + userId: `user-${suite}`, + vendorUserId: `vendor-${suite}`, + }, + verified: true, + probeAction: null, + }); + + const written = await database + .select({ userId: brokeredConnections.userId }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.app, app), + eq(brokeredConnections.provider, "composio"), + ), + ); + expect(written).toHaveLength(1); + expect(written[0]?.userId).toBe(`user-${suite}`); + + const seen = await storeFor("composio").accountsOn(id); + expect(seen?.accounts).toEqual([ + { + holder: "person", + userId: `user-${suite}`, + vendorUserId: `vendor-${suite}`, + }, + ]); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 9d8810dd6..a2ca5c446 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -29,5 +29,17 @@ // type-checked and turning it on surfaces a few hundred years of accumulated `any`. It is what let // a test pass an options object where a connection string belongs and hear nothing back. Its own // change, because it is a sweep and not a fix. - "include": ["src", "scripts", "../scripts/composio-smoke.ts"] + // + // `tests/store-provider-required.test.ts` IS NAMED HERE, ONE FILE AND NOT THE DIRECTORY, for the + // same reason `composio-smoke.ts` is: it is the only test in the tree whose subject is a TYPE. + // `createPluginStore` without `brokerProvider` must not compile, and the `@ts-expect-error` that + // says so is checked by `tsc` or by nothing at all — inside an unchecked `server/tests` it would + // read as an assertion while asserting nothing, which is precisely the silence this pin exists to + // close. Naming the directory is still the sweep described above and still not this change. + "include": [ + "src", + "scripts", + "../scripts/composio-smoke.ts", + "tests/store-provider-required.test.ts" + ] } From 755f57692851e9c13fb3d2dd48be399b8c95873f Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 05:44:36 +0200 Subject: [PATCH 026/232] feat(arcade): compose addBrokeredApp's url, id, provenance and label from the provider addBrokeredApp now reads the store's required brokerProvider and derives from it the url scheme (composio:// or arcade://), the row id prefix (composio- or arcade-), the provenance column and the vendor label. The url check keeps 3.0a's dispatched toolkitOfFor, so it now accepts each store's own url: an Arcade store takes arcade://Toolkit with case kept, a Composio store takes composio://slug exactly as before. brokeredAppRow's lookup url and its id fallback follow the provider too, and the rejection sentence names the provider's vendor. The add-by-URL reservation refuses ids beginning arcade- as it does composio-, with the same sentence shape, on either store: the id prefixes every tool name and is what a grant and a policy rule are written against, so an unreserved namespace is two paths writing one row. The connection count that decides whether a re-add may rewrite the recorded scheme was filtered on a literal "composio"; it reads the store's provider now. That was the last offender in brokered-provenance.test.ts, which is green with access.ts as the only exemption. No offender remains. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 64 ++++-- .../brokered-app-provider.integration.test.ts | 201 ++++++++++++++++++ 2 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 server/tests/brokered-app-provider.integration.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 9fc0c5bb3..da371fdb2 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1107,6 +1107,29 @@ const CLIENT_REREGISTRATION_BACKOFF_MS = 5 * 60_000; */ const IDENTITY_ACTION = /(_GET_ME|_PROFILE|_CURRENT_USER|_USER_INFO|_WHOAMI)$/; +/** + * What each broker's rows are minted as: the url scheme, the id namespace and the vendor label. + * + * ONE ROW PER PROVIDER, KEYED ON {@link BrokerProvider}, so a third broker cannot be enabled until + * it says how its rows are spelled. The provenance column is the provider's own name and needs no + * entry here. The id prefix is what prefixes every tool name and what a grant and a policy rule are + * written against, which is why {@link BROKERED_ID_PREFIXES} reserves every provider's, not only the + * active one's: a deployment that switches broker must not find a typed server already sitting in + * the namespace the other path mints into. + */ +const BROKERED_PROFILE: Record< + BrokerProvider, + { scheme: string; idPrefix: string; vendor: string } +> = { + composio: { + scheme: "composio://", + idPrefix: "composio-", + vendor: "Composio", + }, + arcade: { scheme: "arcade://", idPrefix: "arcade-", vendor: "Arcade" }, +}; +const BROKERED_ID_PREFIXES = Object.values(BROKERED_PROFILE); + /** What a vendor's token endpoint gave back for a refresh token. */ export type AccessToken = { accessToken: string; @@ -2715,7 +2738,7 @@ export function createPluginStore(options: PluginStoreOptions) { async function brokeredAppRow( toolkit: string, ): Promise { - const url = `composio://${toolkit}`; + const url = `${BROKERED_PROFILE[brokerProvider].scheme}${toolkit}`; return (await brokeredAppRowsAt([url])).get(url) ?? null; } @@ -2960,8 +2983,8 @@ export function createPluginStore(options: PluginStoreOptions) { * NOR THE NAMESPACE THE BROKERED ROWS ARE MINTED IN, which is the reservation above one step * further out. * - * CRITERION. No server added by URL may take an id beginning `composio-`, whether or not a row - * is sitting there today. + * CRITERION. No server added by URL may take an id beginning `composio-` or `arcade-`, whether + * or not a row is sitting there today, and on whichever broker this deployment runs. * * REASON. {@link addBrokeredApp} composes its id as `composio-` from the app an * administrator enabled, so that space is already spoken for by a path that writes @@ -2980,10 +3003,12 @@ export function createPluginStore(options: PluginStoreOptions) { * and a policy rule are written against, so a row shadowing another path's namespace inherits * rules that were written about something else. */ - if (input.id.startsWith("composio-")) { - throw new CustomServerRefusedError( - `Names beginning composio- belong to the Composio apps this deployment enables, so ${input.id} is not a name a server added by URL can take. Choose another.`, - ); + for (const { idPrefix, vendor } of BROKERED_ID_PREFIXES) { + if (input.id.startsWith(idPrefix)) { + throw new CustomServerRefusedError( + `Names beginning ${idPrefix} belong to the ${vendor} apps this deployment enables, so ${input.id} is not a name a server added by URL can take. Choose another.`, + ); + } } if (!/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(input.id)) { throw new CustomServerRefusedError( @@ -3157,7 +3182,7 @@ export function createPluginStore(options: PluginStoreOptions) { * * NO URL IS TAKEN FROM A CALLER, WHICH IS WHY THERE IS NO HOST RULE HERE. `addCustomServer` * guards the address because the address is what an administrator typed and what a credential - * would then be spent at; this one composes `composio://` itself, and a brokered row is + * would then be spent at; this one composes `://` itself from the store's provider, and a brokered row is * never dialled at a host at all — the transport reads the app off that url and asks Composio, * over the deployment's own key. So the only thing left to check about the url is that it says * what this call meant, and {@link toolkitOfFor} is what checks it: the slug goes in, the url comes @@ -3201,10 +3226,12 @@ export function createPluginStore(options: PluginStoreOptions) { // chosen from, so there is nothing here to half-do and nothing to say but the setting. if (!broker) throw new BrokerUnconfiguredError(); - const url = `composio://${input.slug}`; + const profile = BROKERED_PROFILE[brokerProvider]; + const url = `${profile.scheme}${input.slug}`; + const rowId = `${profile.idPrefix}${input.slug}`; if (toolkitOfFor(brokerProvider, url) !== input.slug) { throw new CustomServerRefusedError( - `${input.slug} is not a name a Composio app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, + `${input.slug} is not a name ${brokerProvider === "arcade" ? "an" : "a"} ${profile.vendor} app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, ); } @@ -3265,14 +3292,14 @@ export function createPluginStore(options: PluginStoreOptions) { await database .insert(mcpServers) .values({ - id: `composio-${input.slug}`, + id: rowId, title: input.title, logo: input.logo ?? null, // The broker, whoever publishes the app behind it. `vendor` is what the first-party rule // is checked against, and Composio is who this deployment is actually talking to. - vendor: "Composio", + vendor: profile.vendor, url, - provenance: "composio", + provenance: brokerProvider, // Nothing for the vault to hold. A brokered call runs as the person asking, on their own // connection at the vendor, which is a `composio_connections` row rather than a secret. credentialId: null, @@ -3346,8 +3373,8 @@ export function createPluginStore(options: PluginStoreOptions) { * host somebody typed is answering that rule about a server this deployment no longer * dials. */ - provenance: "composio", - vendor: "Composio", + provenance: brokerProvider, + vendor: profile.vendor, addedBy: input.by, updatedAt: new Date(), /* @@ -3397,8 +3424,7 @@ export function createPluginStore(options: PluginStoreOptions) { * insert above and this read disagree about what was stored; it is respelled rather than held * in a binding so that nothing below can take it by mistake. */ - const answering = - (await brokeredAppRow(input.slug))?.id ?? `composio-${input.slug}`; + const answering = (await brokeredAppRow(input.slug))?.id ?? rowId; /** * WHICH ROW EACH ACT OF AN ENABLE NAMES, WRITTEN DOWN BECAUSE NOTHING ELSE CAN SAY IT. @@ -3482,7 +3508,7 @@ export function createPluginStore(options: PluginStoreOptions) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, input.slug), ), ) @@ -3536,7 +3562,7 @@ export function createPluginStore(options: PluginStoreOptions) { // Named for the same reason the custom path names its own: "who enabled an app whose // actions nobody reviewed" is a question somebody will ask, and the answer should not // require knowing how ids were spelled in a past build. - provenance: "composio", + provenance: brokerProvider, }, }); diff --git a/server/tests/brokered-app-provider.integration.test.ts b/server/tests/brokered-app-provider.integration.test.ts new file mode 100644 index 000000000..807f0628a --- /dev/null +++ b/server/tests/brokered-app-provider.integration.test.ts @@ -0,0 +1,201 @@ +/** + * `addBrokeredApp` composes the row's url, id, provenance and vendor label from the store's + * provider, and the add-by-URL path reserves both brokers' id namespaces. + * + * Case is never normalised: Arcade's toolkit names are case-sensitive, so `arcade://Gmail` and + * `arcade://gmail` are distinct apps. + */ +import { afterAll, afterEach, expect, test } from "bun:test"; +import { eq, like, or } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { brokeredConnections, mcpServers } from "../src/db/schema"; +import type { BrokerConnection } from "../src/plugins/broker"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { ComposioBroker } from "../src/plugins/composio"; +import { + CustomServerRefusedError, + createPluginStore, +} from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +async function clean() { + await database + .delete(brokeredConnections) + .where(like(brokeredConnections.app, "bap%")); + await database + .delete(mcpServers) + .where( + or( + like(mcpServers.id, "arcade-bap%"), + like(mcpServers.id, "composio-bap%"), + ), + ); +} +afterEach(clean); +afterAll(async () => { + await clean(); + await database.$client.close(); +}); + +const unasked = (what: string) => async (): Promise => { + throw new Error(`asked the broker to ${what}`); +}; + +function storeFor( + provider: BrokerProvider, + ensure: "created" | "standing" = "created", +) { + const broker: ComposioBroker = { + listApps: unasked("list"), + ensureAuthConfig: async () => ensure, + deleteAuthConfig: unasked("delete"), + authorize: unasked("authorize"), + isConnected: unasked("check"), + revoke: unasked("revoke"), + accountName: async () => null, + }; + return createPluginStore({ + brokerProvider: provider, + database, + auditStore: createAuditStore(database), + credentials: { + readSecret: async () => null, + create: async () => { + throw new Error("vault"); + }, + updateSecret: async () => { + throw new Error("vault"); + }, + revoke: async () => { + throw new Error("vault"); + }, + }, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + }); +} + +const consent: BrokerConnection = { kind: "consent" }; + +async function row(id: string) { + const [found] = await database + .select() + .from(mcpServers) + .where(eq(mcpServers.id, id)); + return found; +} + +test("an Arcade store enables an arcade:// toolkit under an arcade- id, case kept", async () => { + const store = storeFor("arcade"); + const record = await store.addBrokeredApp({ + slug: "bapGmail", + title: "Gmail", + by: "admin@example.com", + connection: consent, + }); + expect(record.id).toBe("arcade-bapGmail"); + expect(record.url).toBe("arcade://bapGmail"); + expect(record.provenance).toBe("arcade"); + const stored = await row("arcade-bapGmail"); + expect(stored?.vendor).toBe("Arcade"); + expect(stored?.provenance).toBe("arcade"); + expect(await row("composio-bapGmail")).toBeUndefined(); + + // Distinct apps: the lower-case spelling is its own row, not the same one. + await store.addBrokeredApp({ + slug: "bapgmail", + title: "gmail", + by: "admin@example.com", + connection: consent, + }); + expect((await row("arcade-bapgmail"))?.url).toBe("arcade://bapgmail"); + expect((await row("arcade-bapGmail"))?.url).toBe("arcade://bapGmail"); +}); + +test("a Composio store still enables a composio:// app under a composio- id", async () => { + const store = storeFor("composio"); + const record = await store.addBrokeredApp({ + slug: "bapslack", + title: "Slack", + by: "admin@example.com", + connection: consent, + }); + expect(record.id).toBe("composio-bapslack"); + expect(record.url).toBe("composio://bapslack"); + expect(record.provenance).toBe("composio"); + expect((await row("composio-bapslack"))?.vendor).toBe("Composio"); + expect(await row("arcade-bapslack")).toBeUndefined(); +}); + +test("each store refuses a slug its own parser cannot read, naming its own vendor", async () => { + await expect( + storeFor("arcade").addBrokeredApp({ + slug: "bap.Dotted", + title: "x", + by: "admin@example.com", + connection: consent, + }), + ).rejects.toThrow(/^bap\.Dotted is not a name an Arcade app can have\./); + await expect( + storeFor("composio").addBrokeredApp({ + slug: "bap dotted", + title: "x", + by: "admin@example.com", + connection: consent, + }), + ).rejects.toThrow(/^bap dotted is not a name a Composio app can have\./); + expect(await row("arcade-bap.Dotted")).toBeUndefined(); + expect(await row("composio-bap dotted")).toBeUndefined(); +}); + +test("re-enabling over a connected Arcade app keeps the recorded scheme", async () => { + // The connection check reads the STORE'S provider: a composio filter would see no connection + // here, treat the re-add as free to rewrite, and move the scheme under a live account. + const store = storeFor("arcade", "created"); + await store.addBrokeredApp({ + slug: "bapkept", + title: "Kept", + by: "admin@example.com", + connection: { kind: "fields", authScheme: "API_KEY" } as BrokerConnection, + }); + const before = (await row("arcade-bapkept"))?.authScheme; + await database.insert(brokeredConnections).values({ + provider: "arcade", + app: "bapkept", + holder: "deployment", + vendorUserId: "deployment", + }); + await store.addBrokeredApp({ + slug: "bapkept", + title: "Kept", + by: "admin@example.com", + connection: consent, + }); + expect((await row("arcade-bapkept"))?.authScheme).toBe(before ?? null); +}); + +for (const provider of ["composio", "arcade"] as const) { + for (const prefix of ["composio-", "arcade-"]) { + test(`a ${provider} store refuses a URL-added server named ${prefix}x`, async () => { + const id = `${prefix}bapx`; + const vendor = prefix === "arcade-" ? "Arcade" : "Composio"; + await expect( + storeFor(provider).addCustomServer({ + id, + title: "Collector", + url: "https://collector.example/mcp", + by: "admin@example.com", + }), + ).rejects.toThrow( + new CustomServerRefusedError( + `Names beginning ${prefix} belong to the ${vendor} apps this deployment enables, so ${id} is not a name a server added by URL can take. Choose another.`, + ).message, + ); + expect(await row(id)).toBeUndefined(); + }); + } +} From 5e4942b0e9bd33f5a85cd7f86c155cce9eef80c8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 09:53:09 +0200 Subject: [PATCH 027/232] fix(arcade): refuse a listing that never ends, and close the review's test gaps - listTools throws when MAX_PAGES is exhausted instead of silently returning a vast duplicated list - provider-selection: replace a test that asserted nothing with the mirrored direction and the singular grammar - arcade-listing: drive the captured catalogue fixture (two met:true tools) through listTools - refresh two stale comments (brokered-provenance guard is green; store.ts is provider-neutral) Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade.ts | 11 ++++++- server/src/plugins/store.ts | 4 +-- server/tests/arcade-listing.test.ts | 40 ++++++++++++++++++++++++ server/tests/brokered-provenance.test.ts | 2 +- server/tests/provider-selection.test.ts | 18 +++++++---- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 52126866b..af9ee5006 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -165,6 +165,7 @@ export async function listTools(connection: { const raw: unknown[] = []; let total: number | undefined; + let exhausted = true; for (let page = 0; page < MAX_PAGES; page++) { const answer = await installed.listTools({ toolkit, @@ -179,7 +180,15 @@ export async function listTools(connection: { } raw.push(...items); if (typeof answer.total_count === "number") total = answer.total_count; - if (items.length < PAGE_SIZE) break; + if (items.length < PAGE_SIZE) { + exhausted = false; + break; + } + } + if (exhausted) { + throw new Error( + `Arcade's listing for ${toolkit} never ended after ${MAX_PAGES} pages, so it is not trusted to be a list of this app's actions. Nothing was refreshed and the actions already recorded for this app are kept.`, + ); } if (total !== undefined && raw.length < total) { throw new Error( diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index da371fdb2..a85ae0ac8 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3183,8 +3183,8 @@ export function createPluginStore(options: PluginStoreOptions) { * NO URL IS TAKEN FROM A CALLER, WHICH IS WHY THERE IS NO HOST RULE HERE. `addCustomServer` * guards the address because the address is what an administrator typed and what a credential * would then be spent at; this one composes `://` itself from the store's provider, and a brokered row is - * never dialled at a host at all — the transport reads the app off that url and asks Composio, - * over the deployment's own key. So the only thing left to check about the url is that it says + * never dialled at a host at all — the transport reads the app off that url and asks the active + * broker, over the deployment's own key. So the only thing left to check about the url is that it says * what this call meant, and {@link toolkitOfFor} is what checks it: the slug goes in, the url comes * back out through the very function `accessFor` will read it with, and a slug those two * disagree about is refused rather than stored. A pattern written here instead would be a second diff --git a/server/tests/arcade-listing.test.ts b/server/tests/arcade-listing.test.ts index 36d0c2c75..44f402772 100644 --- a/server/tests/arcade-listing.test.ts +++ b/server/tests/arcade-listing.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import fixture from "./fixtures/arcade-catalogue.json"; import { listTools, useArcadeClient } from "../src/plugins/arcade"; afterEach(() => useArcadeClient(null)); @@ -78,6 +79,36 @@ describe("listTools scopes", () => { }); }); +describe("listTools against the captured catalogue", () => { + /* + * The captured fixture carries TWO tools with `requirements.met: true` (Asana.AttachFileToTask and + * Attio.RemoveFromList), so the "no met" assertion here is strictly stronger than the one the + * hand-built tools above give it. + */ + test("keeps scopes intact, reads an unscoped oauth2 as none, and never carries met", async () => { + const entries = Object.values( + (fixture as { tools: Record }).tools, + ); + recording(async () => ({ items: entries })); + const tools = await listTools({ url: "arcade://Captured" }); + const byName = (n: string) => tools.find((t) => t.name === n); + + expect(byName("Attio.RemoveFromList")?.scopes).toEqual([ + "call_recording:read", + "list_configuration:read-write", + "list_entry:read-write", + "meeting:read", + "note:read-write", + "object_configuration:read-write", + "record_permission:read-write", + "task:read-write", + "user_management:read", + ]); + expect(byName("AirtableApi.ListBases")?.scopes).toEqual([]); + expect(JSON.stringify(tools)).not.toContain('"met"'); + }); +}); + describe("listTools pagination", () => { test("stops on a short page, with no total_count", async () => { const sent = recording(async (q) => pagesOf(250)(q)); @@ -99,3 +130,12 @@ describe("listTools identity", () => { } }); }); + +describe("listTools runaway guard", () => { + test("a vendor that ignores offset and always returns full pages throws", async () => { + recording(async () => pagesOf(100)({ offset: 0, limit: 100 })); + await expect(listTools({ url: "arcade://Runaway" })).rejects.toThrow( + /never ended/, + ); + }); +}); diff --git a/server/tests/brokered-provenance.test.ts b/server/tests/brokered-provenance.test.ts index a5ba7dc13..4a23a1941 100644 --- a/server/tests/brokered-provenance.test.ts +++ b/server/tests/brokered-provenance.test.ts @@ -5,7 +5,7 @@ * * `access.ts` is the one exempt file because it is where the vocabulary lives. * - * EXPECTED TO FAIL until micro-task 3.2 clears the last offender. + * This guard is green and must stay green; `access.ts` is the only exemption. */ import { expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; diff --git a/server/tests/provider-selection.test.ts b/server/tests/provider-selection.test.ts index 30512b71d..ccb0a1647 100644 --- a/server/tests/provider-selection.test.ts +++ b/server/tests/provider-selection.test.ts @@ -70,11 +70,17 @@ describe("assertActivatable", () => { expect(() => assertActivatable("composio", 0)).not.toThrow(); }); - test("both keys being set, on its own, does not trigger the refusal", () => { - const keys = { composioApiKey: "c", arcadeApiKey: "a" }; - expect(activeProvider({ ...keys, brokerProvider: "arcade" })).toBe( - "arcade", - ); - expect(() => assertActivatable("arcade", 0)).not.toThrow(); + test("one row for the other provider refuses, in the other direction and in the singular", () => { + let caught: unknown; + try { + assertActivatable("composio", 1); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(BrokerRefusalError); + const sentence = brokerSentence(caught); + expect(sentence).toContain("Composio cannot be activated"); + expect(sentence).toContain("1 brokered connection still belongs to Arcade"); + expect(sentence).toContain("sunset"); }); }); From 324e7ce4022ea148e84a12aa4e1cc062c5688edb Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 10:58:32 +0200 Subject: [PATCH 028/232] feat(arcade): the transport half of the SDK error boundary, and a sentence for a failed listing Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade.ts | 100 +++++++++++++++++++++-- server/tests/arcade-errors.test.ts | 127 +++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 server/tests/arcade-errors.test.ts diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index af9ee5006..391ea94db 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -5,7 +5,7 @@ */ import { flagOf } from "./broker"; -import type { ListedTool } from "./mcp"; +import type { ListedTool, McpCallResult } from "./mcp"; /** * Whether listing tools needs somebody's credential. It does not: the listing uses the deployment's @@ -167,11 +167,22 @@ export async function listTools(connection: { let total: number | undefined; let exhausted = true; for (let page = 0; page < MAX_PAGES; page++) { - const answer = await installed.listTools({ - toolkit, - limit: PAGE_SIZE, - offset: raw.length, - }); + let answer: { items: unknown[]; total_count?: number }; + try { + answer = await installed.listTools({ + toolkit, + limit: PAGE_SIZE, + offset: raw.length, + }); + } catch (error) { + /* + * A SENTENCE, NOT THE VENDOR'S OBJECT, as `composio.ts`'s listing does. `refreshTools` puts + * `error.message` on the admin page and into a model's context, and an SDK `APIError` builds + * its message from the whole response body: an unsanitised vendor payload. The original is + * kept as `cause` for a log. + */ + throw new Error(listingSentence(toolkit, error), { cause: error }); + } const items = (answer as { items?: unknown } | null)?.items; if (!Array.isArray(items)) { throw new Error( @@ -211,3 +222,80 @@ export async function listTools(connection: { ]; }); } + +/** What this deployment says when Arcade's listing could not be read. Never the vendor's words. */ +function listingSentence(toolkit: string, _error: unknown): string { + return `Arcade's action list for ${toolkit} could not be read, so nothing was refreshed and the actions already recorded for this app are kept.`; +} + +/** + * A failure from Arcade, already normalized and cause-unwrapped by `./arcade-adapter`, so nothing + * here sees an SDK class. `name` is the RESPONSE BODY's `error.name`. + */ +export type ArcadeFailure = { + status: number | undefined; + name: string | undefined; + message: string | undefined; +}; + +/** + * KEYED ON THE BODY'S NAME, NEVER ON THE 403. A 403 is also what a revoked key, a disabled project + * and a rate limit look like; keyed on the status, this would tell somebody to authorize an action + * when the deployment's key is what is wrong. + */ +export function isAuthorizationRequired(failure: ArcadeFailure): boolean { + return failure.name === "tool_authorization_required"; +} + +const failureResult = (text: string): McpCallResult => ({ + text, + isError: true, + truncated: false, +}); + +/** + * Call one Arcade action as the connection's actor. + * + * THE ACTION IN THE REFUSAL COMES FROM THE CALL SITE, never from the error: `APIError` in + * `@arcadeai/arcadejs@2.4.1` carries `status`, `headers` and `error` and nothing naming the tool. + * + * NO URL IS EVER IN THE SENTENCE. The 403 envelope's `authorization.url` is a bearer capability: + * whoever opens it attaches an account to the identity the flow was minted for. It is never + * returned to a model, logged, put in an error body or written to an audit row. The refusal names + * the Settings page, where the link is minted by an authenticated request whose actor is known. + * + * NOTHING THE VENDOR SAID IS REPEATED either: the ordinary failure is this deployment's sentence. + */ +export async function callTool( + connection: { url: string; actorId?: string }, + toolName: string, + args: Record, +): Promise { + if (!installed) { + return failureResult( + `Arcade is not configured for this deployment, so ${toolName} was not run.`, + ); + } + if (!connection.actorId) { + return failureResult( + `${toolName} was not run: no account was named to run it as.`, + ); + } + try { + const answer = await installed.execute({ + tool_name: toolName, + input: args, + user_id: connection.actorId, + }); + const text = typeof answer === "string" ? answer : JSON.stringify(answer); + return { text: text ?? "", isError: false, truncated: false }; + } catch (thrown) { + const failure = record(thrown) as Partial | null; + if (failure && isAuthorizationRequired(failure as ArcadeFailure)) { + return failureResult( + `${toolName} has not been authorized on this account yet. Authorize it in Settings → Connected accounts and ask again.`, + ); + } + return failureResult(`Arcade could not run ${toolName}.`); + } +} diff --git a/server/tests/arcade-errors.test.ts b/server/tests/arcade-errors.test.ts new file mode 100644 index 000000000..e8ecd2440 --- /dev/null +++ b/server/tests/arcade-errors.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + callTool, + isAuthorizationRequired, + listTools, + useArcadeClient, + type ArcadeFailure, +} from "../src/plugins/arcade"; + +afterEach(() => useArcadeClient(null)); + +const connection = { url: "arcade://Gmail", actorId: "u1" }; +const URL_IN_BODY = "https://github.com/login/oauth/authorize?state=SECRET"; + +function failing(failure: unknown) { + useArcadeClient({ + listTools: async () => { + throw new Error("not listing"); + }, + execute: async () => { + throw failure; + }, + }); +} + +describe("isAuthorizationRequired", () => { + test("keys on the body's error name", () => { + expect( + isAuthorizationRequired({ + status: 403, + name: "tool_authorization_required", + message: "authorization required", + }), + ).toBe(true); + }); + + test("the name alone is enough: it never reads the status", () => { + expect( + isAuthorizationRequired({ + status: undefined, + name: "tool_authorization_required", + message: undefined, + }), + ).toBe(true); + }); + + test("a 403 is not an authorization prompt", () => { + for (const name of ["permission_denied", undefined, "rate_limited"]) { + expect(isAuthorizationRequired({ status: 403, name, message: "x" })).toBe( + false, + ); + } + }); +}); + +describe("callTool failures", () => { + test("an authorization-required failure names the action callTool was given", async () => { + failing({ + status: 403, + name: "tool_authorization_required", + message: `authorization required ${URL_IN_BODY}`, + } satisfies ArcadeFailure); + const result = await callTool(connection, "Gmail.SendEmail", {}); + expect(result.isError).toBe(true); + expect(result.text).toBe( + "Gmail.SendEmail has not been authorized on this account yet. Authorize it in Settings → Connected accounts and ask again.", + ); + expect(result.text).not.toContain("http"); + expect(result.text).not.toContain("SECRET"); + }); + + test("the action comes from the call site, not from anything on the error", async () => { + failing({ + status: 403, + name: "tool_authorization_required", + message: "m", + toolName: "Other.Thing", + }); + const result = await callTool(connection, "Slack.Post", {}); + expect(result.text.startsWith("Slack.Post has not been authorized")).toBe( + true, + ); + }); + + test("permission_denied at 403 is NOT an authorize prompt", async () => { + failing({ status: 403, name: "permission_denied", message: "key revoked" }); + const result = await callTool(connection, "Gmail.SendEmail", {}); + expect(result.isError).toBe(true); + expect(result.text).not.toContain("has not been authorized"); + expect(result.text).not.toContain("Connected accounts"); + expect(result.text).not.toContain("http"); + }); + + test("a throw that is not an ArcadeFailure is handled", async () => { + for (const thrown of [new Error("boom"), "string", null, undefined, 42]) { + failing(thrown); + const result = await callTool(connection, "Gmail.SendEmail", {}); + expect(result.isError).toBe(true); + expect(result.text).not.toContain("has not been authorized"); + } + }); +}); + +describe("listTools failures", () => { + test("a vendor error on the listing path does not leak the vendor's message", async () => { + const vendor = new Error( + '403 {"name":"permission_denied","message":"LEAKME key sk-123"}', + ); + useArcadeClient({ + listTools: async () => { + throw vendor; + }, + execute: async () => { + throw new Error("no"); + }, + }); + const error = await listTools(connection).then( + () => null, + (e: Error) => e, + ); + expect(error).not.toBeNull(); + expect(error!.message).not.toContain("LEAKME"); + expect(error!.message).not.toContain("sk-123"); + expect(error!.message).toContain("Gmail"); + expect(error!.cause).toBe(vendor); + }); +}); From eaef8aa06fbc28b1a8b14cf555263e5046b239e9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:01:08 +0200 Subject: [PATCH 029/232] refactor(arcade): read the shared-use inbox's urls through the provider's own parser createSharedUseStore now takes the broker provider as a REQUIRED parameter, with no default, the same contract as PluginStoreOptions.brokerProvider. index.ts hoists the one `activeProvider(config) ?? "composio"` into a const and gives the same value to this store and to the plugin store. The file's toolkitOf import is replaced by access.ts's toolkitOfFor, every canonical url is built from the provider's scheme, and the raw SQL `like` prefix is now a bound parameter derived from that scheme rather than a literal in the template. Why: this was not a crash. Every path here guards on a non-null toolkit, so with Composio's parser an Arcade url simply failed to parse and the file degraded to "every row is its own app", which is indistinguishable from correct for one row per app. The defect is the asymmetry: store.ts groups Arcade rows by app through the provider dispatch and this file did not. The docblock on rowsOfApp says an approval filed at a non-canonical url can never be found by the gate, which asks at the canonical url and nowhere else; a gate and the approvals it reads keyed on different things is the failed-open shape reached from the other side. Measured consequence on Arcade: a revocation sweep covered only the dialled row and missed Bots granted through a sibling row at the same app url, so fewer Bots were revoked than the admin asked for and nothing errored. The `like` line was inert only because it sits inside `if (toolkit)`, which was already null on Arcade; fixing the parser is what makes it matter, so it now follows the active scheme. Docblocks restate the canonical-url argument in scheme-neutral spelling; the reasoning is unchanged. No inbox decision, status, copy or column changes. Existing test call sites pass "composio". Co-Authored-By: Claude Opus 5 (1M context) --- server/src/index.ts | 20 ++-- server/src/plugins/shared-use-store.ts | 57 ++++++--- server/tests/account-mode.integration.test.ts | 2 +- server/tests/shared-brokered-routes.test.ts | 8 +- server/tests/shared-use-arcade-urls.test.ts | 113 ++++++++++++++++++ .../shared-use-store.integration.test.ts | 2 +- .../tests/shared-wiring.integration.test.ts | 4 +- 7 files changed, 173 insertions(+), 33 deletions(-) create mode 100644 server/tests/shared-use-arcade-urls.test.ts diff --git a/server/src/index.ts b/server/src/index.ts index e29e334d9..ba2cbad3d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -734,7 +734,16 @@ const signInService = computerGateway * module scope because the publication, assignment and grant routes below also need to reapprove * or refile against it when a Bot's exposure changes. */ -const sharedUseStore = createSharedUseStore(database); +/* + * `activeProvider` answers `null` only when this deployment has no broker at all: no key is set, + * so no broker reaches the store, no brokered row exists, and the value merely names which empty + * slice an unreachable query would read. That is why the one `??` in the system is HERE and only + * here — the single place where "which provider" has no answer, visible at the deployment boundary + * instead of asserted forty times over inside a constructor. The shared-use store and the plugin + * store below are both given this one value, so the gate and the approvals it reads agree. + */ +const brokerProvider = activeProvider(config) ?? "composio"; +const sharedUseStore = createSharedUseStore(database, brokerProvider); const pluginStore = createPluginStore({ database, @@ -766,14 +775,7 @@ const pluginStore = createPluginStore({ * attempted. */ broker: composio?.broker, - /* - * `activeProvider` answers `null` only when this deployment has no broker at all: no key is set, - * so no broker reaches the store, no brokered row exists, and the value merely names which empty - * slice an unreachable query would read. That is why the one `??` in the system is HERE and only - * here — the single place where "which provider" has no answer, visible at the deployment boundary - * instead of asserted forty times over inside a constructor. - */ - brokerProvider: activeProvider(config) ?? "composio", + brokerProvider, }); /* diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts index c63c345ad..07f3edf8d 100644 --- a/server/src/plugins/shared-use-store.ts +++ b/server/src/plugins/shared-use-store.ts @@ -20,7 +20,7 @@ import { teamBotPublications, users, } from "../db/schema"; -import { toolkitOf } from "./composio"; +import { type BrokerProvider, toolkitOfFor } from "./access"; import { type ActorFacts, type ApprovalMember, @@ -73,12 +73,33 @@ export type SharedUseGate = (input: { const sameApproval = (left: SharedUseApproval, right: SharedUseApproval) => covers(left, right) && covers(right, left); -export function createSharedUseStore(database: Database) { +/** + * The url scheme a broker's apps live at. A broker's scheme is its provider's name, `://`, + * the same identity the `provenance` column already leans on (see {@link BrokerProvider}), so it is + * derived from the provider rather than spelled as a literal a third broker would have to be + * remembered in. + */ +const schemeOf = (provider: BrokerProvider) => `${provider}://`; + +/** + * `provider` IS REQUIRED, with no default, for the reason `PluginStoreOptions.brokerProvider` is: + * this store has to read urls with the same parser, at the same scheme, as the store whose gate + * consults the approvals filed here. A default would let the two disagree silently, which is the + * failed-open shape the canonical-url rule below exists to prevent. The caller passes the one value + * the plugin store is given. + */ +export function createSharedUseStore( + database: Database, + provider: BrokerProvider, +) { const roles = createRoleRepository(database); + const scheme = schemeOf(provider); + const toolkitOf = (url: string) => toolkitOfFor(provider, url); + const canonicalUrlOf = (toolkit: string) => `${scheme}${toolkit}`; /* * EVERY ROW AT AN APP'S CANONICAL URL, LOWEST ID FIRST — THE ANSWERING ROW IS THE HEAD. Several - * `mcp_servers` rows may point at one `composio://` url (the url has no unique index, + * `mcp_servers` rows may point at one `` url (the url has no unique index, * deliberately), and the store's `brokeredAppRowsAt` names the row that answers for the app as * the first one SQL's `order by id` yields at that url. This is the same rule spelled with the * same `order by`, so the deployment's collation decides here exactly as it decides there — a @@ -86,19 +107,19 @@ export function createSharedUseStore(database: Database) { * than imported because the store's helper is a closure inside `createStore`, and this store is * built from the database alone. * - * "AT THE URL" MEANS THE CANONICAL ONE, `composio://${toolkitOf(url)}`, NOT THE DIALLED ROW'S OWN - * url string. `toolkitOf` (`./composio`) trims whitespace and a trailing slash before it reads - * the slug, so a row stored as `composio://github/` or `composio://github ` names the toolkit - * `github` exactly as `composio://github` does — and the store's own gate (`brokeredAppRow`, + * "AT THE URL" MEANS THE CANONICAL ONE, `${scheme}${toolkitOf(url)}`, NOT THE DIALLED ROW'S OWN + * url string. `toolkitOf` (the active provider's own parser, via `toolkitOfFor`) trims whitespace and a trailing slash before it reads + * the slug, so a row stored as `github/` or `github ` names the toolkit + * `github` exactly as `github` does — and the store's own gate (`brokeredAppRow`, * `accountRefFor`) only ever looks rows up at the trimmed, canonical address. Grouping by the raw * stored url instead, as this used to, made such a row an app of its own: an approval granted or * read through it could never be found by the gate, which asks at the canonical url and nowhere * else. So THIS is "the same rule" the comment above claims — same `order by`, same address the * gate itself resolves to. * - * A row whose url is not `composio://` at all, or whose slug `toolkitOf` cannot parse, is an app + * A row whose url is not at the provider's scheme at all, or whose slug `toolkitOf` cannot parse, is an app * of its own and is the only row in its answer — the same two cases the store's own gate lets - * stand rather than refuses (`accountRefFor` reads `toolkitOf` only for a `composio` provenance, + * stand rather than refuses (`accountRefFor` reads `toolkitOf` only for a row provenanced at the active provider, * and a null result there is read as "not brokered", not as a refusal). * * NO ROW AT THE CANONICAL URL is answered by falling back to the dialled `serverId` itself, which @@ -124,7 +145,7 @@ export function createSharedUseStore(database: Database) { const url = row?.url ?? null; const toolkit = url ? toolkitOf(url) : null; if (!toolkit) return { appId: serverId, url, ids: [serverId] }; - const canonical = `composio://${toolkit}`; + const canonical = canonicalUrlOf(toolkit); const siblings = await database .select({ id: mcpServers.id }) .from(mcpServers) @@ -373,7 +394,7 @@ export function createSharedUseStore(database: Database) { * * "ITS URL" IS THE CANONICAL ONE A GRANTED ROW RESOLVES TO THROUGH `toolkitOf`, same as * `rowsOfApp` above, and not the row's own stored url compared by prefix. A granted row stored as - * `composio://github/` is the toolkit `github` exactly as `composio://github` is, and grouping it + * `github/` is the toolkit `github` exactly as `github` is, and grouping it * by its raw url instead left it unmatched against the answering row at the trimmed address — so * a Bot holding the app only through such a row was listed under a row of its own, or dropped, * rather than under the one row the gate actually answers for the app. @@ -401,7 +422,7 @@ export function createSharedUseStore(database: Database) { granted .map((row) => toolkitOfRow(row.url)) .filter((toolkit): toolkit is string => !!toolkit) - .map((toolkit) => `composio://${toolkit}`), + .map(canonicalUrlOf), ), ]; const atUrls = @@ -421,7 +442,7 @@ export function createSharedUseStore(database: Database) { granted.map((row) => { const toolkit = toolkitOfRow(row.url); if (!toolkit) return row.id; - return answeringAt.get(`composio://${toolkit}`) ?? row.id; + return answeringAt.get(canonicalUrlOf(toolkit)) ?? row.id; }), ), ]; @@ -445,9 +466,9 @@ export function createSharedUseStore(database: Database) { * "ANY ROW AT ITS URL" HAS TO MEAN EVERY ROW `toolkitOf` RESOLVES TO THE SAME TOOLKIT, NOT ONLY * THE ONES `rowsOfApp` FOUND AT THE EXACT CANONICAL URL. `rowsOfApp`'s `ids` already covers the * canonical rows plus the row actually dialled, but a grant can be made through a THIRD row — - * neither the dialled one nor stored at the trimmed address, e.g. `composio:///` sitting + * neither the dialled one nor stored at the trimmed address, e.g. `/` sitting * elsewhere in the table — and that row never surfaces from an exact `eq` on the canonical url. So - * every `composio://` row is read once here and matched in JavaScript by `toolkitOf`, which is the + * every row at the provider's scheme is read once here and matched in JavaScript by `toolkitOf`, which is the * one place that defines what "the same toolkit" means. */ async function botsHolding(serverId: string): Promise { @@ -455,11 +476,11 @@ export function createSharedUseStore(database: Database) { const toolkit = url ? toolkitOf(url) : null; let allIds = ids; if (toolkit) { - const composioRows = await database + const schemeRows = await database .select({ id: mcpServers.id, url: mcpServers.url }) .from(mcpServers) - .where(sql`${mcpServers.url} like 'composio://%'`); - const matching = composioRows + .where(sql`${mcpServers.url} like ${`${scheme}%`}`); + const matching = schemeRows .filter((row) => row.url && toolkitOf(row.url) === toolkit) .map((row) => row.id); allIds = [...new Set([...ids, ...matching])]; diff --git a/server/tests/account-mode.integration.test.ts b/server/tests/account-mode.integration.test.ts index 230287d21..947ded15b 100644 --- a/server/tests/account-mode.integration.test.ts +++ b/server/tests/account-mode.integration.test.ts @@ -60,7 +60,7 @@ const store = createPluginStore({ deploymentId: "acme", sharedUse: async () => ({ allowed: true }), }); -const sharedUse = createSharedUseStore(database); +const sharedUse = createSharedUseStore(database, "composio"); const modes = createAccountModeSwitch({ store, sharedUse, diff --git a/server/tests/shared-brokered-routes.test.ts b/server/tests/shared-brokered-routes.test.ts index 2acc78cb0..7a8bd4b6d 100644 --- a/server/tests/shared-brokered-routes.test.ts +++ b/server/tests/shared-brokered-routes.test.ts @@ -39,6 +39,8 @@ const rules: { }[] = []; const broker: ConnectedAppBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, @@ -72,7 +74,7 @@ function appAs(role: "admin" | "user", id = `${role}-${suite}`) { broker, deploymentId: `acme-${suite}`, }); - const sharedUseStore = createSharedUseStore(database); + const sharedUseStore = createSharedUseStore(database, "composio"); const modes = createAccountModeSwitch({ store, sharedUse: sharedUseStore, @@ -376,7 +378,7 @@ describe("granting a shared app's action", () => { }); expect(response.status).toBe(200); expect( - await createSharedUseStore(database).approvalFor(botId, app), + await createSharedUseStore(database, "composio").approvalFor(botId, app), ).toEqual({ audience: "team", outsideInput: false, members: [] }); }); @@ -392,7 +394,7 @@ describe("granting a shared app's action", () => { }), }); expect( - await createSharedUseStore(database).approvalFor(botId, app), + await createSharedUseStore(database, "composio").approvalFor(botId, app), ).toEqual({ audience: "owner", outsideInput: false, members: [] }); }); diff --git a/server/tests/shared-use-arcade-urls.test.ts b/server/tests/shared-use-arcade-urls.test.ts new file mode 100644 index 000000000..c33c56ce4 --- /dev/null +++ b/server/tests/shared-use-arcade-urls.test.ts @@ -0,0 +1,113 @@ +// server/tests/shared-use-arcade-urls.test.ts +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { + agentProfiles, + agents, + mcpServers, + pluginGrants, + users, +} from "../src/db/schema"; +import type { BrokerProvider } from "../src/plugins/access"; +import { createSharedUseStore } from "../src/plugins/shared-use-store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const id = (name: string) => `${name}-${suite}`; + +/** Two rows at one app url, the lower id first, and a Bot granted through the HIGHER one only. */ +const lowId = id("a-row"); +const highId = id("b-row"); +const bot = id("bot"); +const owner = id("owner"); + +const spec: Record = { + composio: { toolkit: `gmail${suite}`, scheme: "composio://" }, + arcade: { toolkit: `Gmail${suite}`, scheme: "arcade://" }, +}; + +async function cleanup() { + await database.delete(agents).where(eq(agents.id, bot)); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, [lowId, highId])); + await database.delete(users).where(eq(users.id, owner)); +} + +async function seed(provider: BrokerProvider) { + const url = `${spec[provider].scheme}${spec[provider].toolkit}`; + await database.insert(users).values({ + id: owner, + email: `${owner}@example.test`, + name: "owner", + emailVerified: true, + groups: [], + }); + for (const rowId of [lowId, highId]) + await database.insert(mcpServers).values({ + id: rowId, + title: `Team ${rowId}`, + vendor: provider, + url, + provenance: provider, + authScheme: "OAUTH2", + accountMode: "shared", + sharedVendorUserId: "openbot-deployment:x:R", + }); + await database + .insert(agents) + .values({ id: bot, name: "Triage", type: "built_in", configuration: {} }); + await database.insert(agentProfiles).values({ + agentId: bot, + ownerUserId: owner, + visibility: "private", + title: "Triage", + roleDescription: "Triage", + avatarSeed: "x", + }); + await database.insert(pluginGrants).values({ + kind: "mcp", + ref: `${highId}/ACTION`, + agentId: bot, + grantedBy: "admin@example.test", + }); +} + +beforeEach(cleanup); +afterAll(cleanup); + +for (const provider of ["arcade", "composio"] as const) { + describe(`shared use urls under ${provider}`, () => { + test("two rows at one url are ONE app and the lower id answers", async () => { + await seed(provider); + const store = createSharedUseStore(database, provider); + expect(await store.appIdOf(highId)).toBe(lowId); + expect(await store.appIdOf(lowId)).toBe(lowId); + }); + + test("sharedAppsHeldBy lists the app, under the answering row", async () => { + await seed(provider); + const store = createSharedUseStore(database, provider); + const held = await store.sharedAppsHeldBy(bot); + expect(held.map((app) => app.serverId)).toEqual([lowId]); + }); + + test("botsHolding finds a Bot granted through the sibling row", async () => { + await seed(provider); + const store = createSharedUseStore(database, provider); + expect(await store.botsHolding(lowId)).toEqual([bot]); + expect(await store.botsHolding(highId)).toEqual([bot]); + }); + + test("an approval filed through either row is one fact", async () => { + await seed(provider); + const store = createSharedUseStore(database, provider); + expect(await store.approvalFor(bot, highId)).toEqual( + await store.approvalFor(bot, lowId), + ); + }); + }); +} diff --git a/server/tests/shared-use-store.integration.test.ts b/server/tests/shared-use-store.integration.test.ts index 534d04eb3..8c226408b 100644 --- a/server/tests/shared-use-store.integration.test.ts +++ b/server/tests/shared-use-store.integration.test.ts @@ -27,7 +27,7 @@ const suite = randomUUID().slice(0, 8); const id = (name: string) => `${name}-${suite}`; const app = id("gh"); const bot = id("bot"); -const store = createSharedUseStore(database); +const store = createSharedUseStore(database, "composio"); const gate = createSharedUseGate(store, createAuditStore(database)); async function seedUser(name: string, groups: string[] = []) { diff --git a/server/tests/shared-wiring.integration.test.ts b/server/tests/shared-wiring.integration.test.ts index a55e3a9d0..d0b7fd819 100644 --- a/server/tests/shared-wiring.integration.test.ts +++ b/server/tests/shared-wiring.integration.test.ts @@ -25,6 +25,8 @@ const suite = randomUUID().slice(0, 8); const app = `wired-${suite}`; const broker: ConnectedAppBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, @@ -49,7 +51,7 @@ function build(options: { shared: boolean }) { broker, deploymentId: `acme-${suite}`, }); - const use = createSharedUseStore(database); + const use = createSharedUseStore(database, "composio"); const modes = createAccountModeSwitch({ store: pluginStore, sharedUse: use, From 3a87848a661461b982226ec70eeee1c6c924ed35 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:02:18 +0200 Subject: [PATCH 030/232] feat(arcade): gate the connection row on the broker's grantIsLocal, not a vendor name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brokered call demanded a `brokered_connections` row before it could go out. That row is the right precondition only where THIS deployment holds the grant. Where the vendor holds it, the vendor checks per action on every call, and a local row would be a second, weaker copy of that check — the copy that forces a Connect button and so an arbitrary first consent. The exemption reads the broker's declared `grantIsLocal`. Never a `provider === "arcade"` comparison, never the row's provenance, never its url: `store.ts`'s older gate and its exemption keyed on different things and the exemption failed OPEN, and a vendor's name compared inside a permission check is that same shape. A declared property is checkable against every adapter and a third broker answers it by existing. ONLY A PRECONDITION IS REMOVED. The identity returned is the same `resolved.ref.vendorUserId` the row branch returns, derived by `accountRefFor` from the session's actor and the app's own mode — a Shared app still goes out as the deployment, whoever asks. The broker stubs under `server/tests` modelled Composio but omitted the required `grantIsLocal`; they now declare the `true` the real adapter does, so no Composio behaviour moves. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 32 +++ .../brokered-app-provider.integration.test.ts | 2 + .../tests/composio-connection-kinds.test.ts | 2 + server/tests/composio-connections.test.ts | 2 + server/tests/gate-grant-is-local.test.ts | 196 ++++++++++++++++++ .../mcp-grant-removal.integration.test.ts | 2 + server/tests/plugin-store.integration.test.ts | 4 + .../remote-bot-handoff.integration.test.ts | 2 + .../shared-brokered-store.integration.test.ts | 2 + .../shared-gated-mode.integration.test.ts | 2 + 10 files changed, 246 insertions(+) create mode 100644 server/tests/gate-grant-is-local.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index a85ae0ac8..b773500f0 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1751,6 +1751,38 @@ export function createPluginStore(options: PluginStoreOptions) { `${row.title} changed how it is shared while this call was being checked, so it was not called. Ask again.`, null, ); + /* + * A ROW IS ONLY REQUIRED WHERE THIS DEPLOYMENT IS THE ONE HOLDING THE GRANT. + * + * CRITERION. Whether a brokered call has to find a `brokered_connections` row first is the + * broker's DECLARED {@link ConnectedAppBroker.grantIsLocal} — never a `provider === "arcade"` + * comparison, never a test of the row's provenance column, never a look at its url. The + * question is "where does the grant live", the field answers exactly that question, and the + * vendor's name is not an answer to it. + * + * REASON. The gate just below and its `none` exemption above once keyed on DIFFERENT things, + * and the exemption failed OPEN: a duplicate row recording `NO_AUTH` at a key app's url + * skipped the per-person gate entirely, and the deployment's own key ran a call for somebody + * who had connected nothing. A vendor's name compared inside a check whose vocabulary is + * about permission is that same shape — two answers to one question with nothing forcing them + * to agree, and a third broker would mean finding every such comparison by hand. A declared + * property is checkable against every adapter, reads the same as the `listNeedsCredential` + * beside it, and a third provider answers it by existing or fails to type-check. + * + * WHY THE ROW IS NOT REQUIRED WHERE THE GRANT IS THE VENDOR'S. Such a broker checks per + * action, on every call, at the vendor. A local row demanding to exist first would be a + * second, weaker copy of that check — and it is the copy that forces a Connect button, which + * forces an arbitrary first consent before anybody knows which action they want. Here the + * call proceeds and the vendor's own typed refusal is the authoritative answer. + * + * ONLY A PRECONDITION IS REMOVED; THE IDENTITY IS UNCHANGED. The id returned is + * `resolved.ref.vendorUserId` — the very value the branch below returns out of the row — + * derived by {@link accountRefFor} from the session's actor and the app's own mode, Personal + * or Shared, exactly as before. The model cannot name an identity, nothing about whose + * account a call runs in changes here, and the Shared case still goes out as the deployment. + */ + if (broker && !broker.grantIsLocal) + return { vendorUserId: resolved.ref.vendorUserId }; const [connected] = await database .select({ vendorUserId: brokeredConnections.vendorUserId }) .from(brokeredConnections) diff --git a/server/tests/brokered-app-provider.integration.test.ts b/server/tests/brokered-app-provider.integration.test.ts index 807f0628a..9bafae30c 100644 --- a/server/tests/brokered-app-provider.integration.test.ts +++ b/server/tests/brokered-app-provider.integration.test.ts @@ -49,6 +49,8 @@ function storeFor( ensure: "created" | "standing" = "created", ) { const broker: ComposioBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: unasked("list"), ensureAuthConfig: async () => ensure, deleteAuthConfig: unasked("delete"), diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index 39513ae53..d5d3b270d 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -659,6 +659,8 @@ const credentialsStub: CredentialSecretReader & CredentialStore = { let brokerAsks: string[] = []; const broker: ConnectedAppBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: async () => { throw new Error("nothing here lists the catalogue"); }, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 08b33863e..5f1f37e0d 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -680,6 +680,8 @@ let vendorHolds: string[] = []; let configWasStanding = false; const broker: ComposioBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: unasked("list the catalogue"), ensureAuthConfig: async (config) => { // Named by app AND kind, because the kind is what decides which config is created: an enable diff --git a/server/tests/gate-grant-is-local.test.ts b/server/tests/gate-grant-is-local.test.ts new file mode 100644 index 000000000..35f279da3 --- /dev/null +++ b/server/tests/gate-grant-is-local.test.ts @@ -0,0 +1,196 @@ +// server/tests/gate-grant-is-local.test.ts +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { + agents, + brokeredConnections, + mcpServers, + mcpTools, +} from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { useComposioClient } from "../src/plugins/composio"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const shared = `team-gh-${suite}`; +const personal = `my-gh-${suite}`; +const bot = `bot-${suite}`; +const admin = `admin-${suite}`; +const asker = `asker-${suite}`; +const VENDOR_ID = `openbot-deployment:acme-${suite}:R`; + +/* + * THE ONE VARIABLE IN THIS FILE IS A DECLARED PROPERTY, NOT A VENDOR NAME. + * + * Every store below is built with `brokerProvider: "composio"`, over Composio's own url scheme, + * provenance and transport. The only thing that differs between the refusing case and the + * proceeding one is `grantIsLocal` on the broker object — which is the assertion that the gate in + * `connectionTokenFor` reads the property and could not be reading the vendor's name, because the + * name is identical in all three tests. + */ +const brokerStub: Omit = { + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme-bot", +}; + +const policy = { mode: "enforce" as const, deny: [], allow: ["true"] }; +const credentialsStub = {} as never; + +/** The ids the transport was actually handed, which is the only place identity is observable. */ +function recordingTransport(sentAs: string[]) { + useComposioClient({ + listActions: async () => [], + execute: async ({ userId }) => { + sentAs.push(userId); + return { successful: true, data: {}, error: null } as never; + }, + }); +} + +function storeWith(grantIsLocal: boolean) { + return createPluginStore({ + brokerProvider: "composio", + database, + auditStore: createAuditStore(database), + credentials: credentialsStub, + encryptionKey: "x".repeat(44), + policy: () => policy, + broker: { ...brokerStub, grantIsLocal }, + deploymentId: `acme-${suite}`, + sharedUse: async () => ({ allowed: true }), + }); +} + +/** An app at its own url, with one action and NO connection row anywhere. */ +async function seed(app: string, mode: "shared" | "personal") { + await database.insert(mcpServers).values({ + id: app, + title: "Team GitHub", + vendor: "Composio", + url: `composio://${app}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: mode, + sharedVendorUserId: mode === "shared" ? VENDOR_ID : null, + }); + await database.insert(mcpTools).values({ + serverId: app, + name: "GITHUB_LIST_ISSUES", + description: "List issues.", + effect: "read", + version: "1", + }); +} + +async function clean() { + for (const app of [shared, personal]) { + await database + .delete(brokeredConnections) + .where(eq(brokeredConnections.app, app)); + await database.delete(mcpServers).where(eq(mcpServers.id, app)); + } + await database.delete(agents).where(eq(agents.id, bot)); +} + +beforeEach(async () => { + await clean(); + await database + .insert(agents) + .values({ id: bot, name: "Helper", type: "built_in", configuration: {} }); +}); +afterEach(async () => { + useComposioClient(null); + await clean(); +}); + +describe("connectionTokenFor asks the broker where the grant lives", () => { + test("a local grant with no connection row refuses in today's words", async () => { + const sentAs: string[] = []; + recordingTransport(sentAs); + const store = storeWith(true); + await seed(shared, "shared"); + await store.grant( + "mcp", + `${shared}/GITHUB_LIST_ISSUES`, + bot, + `${admin}@example.test`, + ); + + await expect( + store.callTool({ + ref: `${shared}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }), + ).rejects.toThrow( + "Team GitHub is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.", + ); + expect(sentAs).toEqual([]); + }); + + test("a vendor-held grant with no connection row lets the call through", async () => { + const sentAs: string[] = []; + recordingTransport(sentAs); + const store = storeWith(false); + await seed(personal, "personal"); + await store.grant( + "mcp", + `${personal}/GITHUB_LIST_ISSUES`, + bot, + `${admin}@example.test`, + ); + + const answer = await store.callTool({ + ref: `${personal}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }); + expect(answer.isError).toBe(false); + expect(sentAs).toEqual([asker]); + }); + + /* + * THE ASSERTION THAT THIS REMOVED A PRECONDITION AND NOT AN AUTHORIZATION. + * + * A Shared app still acts as the deployment, whoever asks and whether or not a row exists. The + * identity is derived from the app's mode exactly as before — the asker cannot name it — so what + * the transport is handed is the app's `shared_vendor_user_id`, never `asker`. + */ + test("a vendor-held grant on a Shared app still runs as the app's shared identity", async () => { + const sentAs: string[] = []; + recordingTransport(sentAs); + const store = storeWith(false); + await seed(shared, "shared"); + await store.grant( + "mcp", + `${shared}/GITHUB_LIST_ISSUES`, + bot, + `${admin}@example.test`, + ); + + const answer = await store.callTool({ + ref: `${shared}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }); + expect(answer.isError).toBe(false); + expect(sentAs).toEqual([VENDOR_ID]); + expect(sentAs).not.toContain(asker); + }); +}); diff --git a/server/tests/mcp-grant-removal.integration.test.ts b/server/tests/mcp-grant-removal.integration.test.ts index 0d6b67efc..32a9a260e 100644 --- a/server/tests/mcp-grant-removal.integration.test.ts +++ b/server/tests/mcp-grant-removal.integration.test.ts @@ -23,6 +23,8 @@ const keptRef = `${keptServerId}/${keptAction}`; const botId = `agent_grant_removal_${suite}`; const broker: ConnectedAppBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: async () => [], ensureAuthConfig: async () => undefined, deleteAuthConfig: async () => undefined, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 1f988b5b1..46947e698 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6612,6 +6612,8 @@ test("enabling an app writes a brokered row, and asks for its auth config first" throw new Error(`enabling an app asked the broker to ${what}`); }; const broker: ComposioBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: unasked("list the catalogue"), ensureAuthConfig: async (config) => { asked.push(config); @@ -6785,6 +6787,8 @@ function brokerSpy(answers: { return { order, broker: { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: unasked("list the catalogue"), ensureAuthConfig: asked( "ensureAuthConfig", diff --git a/server/tests/remote-bot-handoff.integration.test.ts b/server/tests/remote-bot-handoff.integration.test.ts index 9487a58eb..8dbaaf620 100644 --- a/server/tests/remote-bot-handoff.integration.test.ts +++ b/server/tests/remote-bot-handoff.integration.test.ts @@ -35,6 +35,8 @@ const ACTOR = `remote-handoff-actor-${suite}`; const ALL = [REMOTE, BUILT_IN, OTHER_REMOTE]; const broker: ConnectedAppBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: async () => [], ensureAuthConfig: async () => undefined, deleteAuthConfig: async () => undefined, diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index c8413b30e..fe0735a87 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -32,6 +32,8 @@ const duplicate = `zz-dup-${suite}`; const revoked: string[] = []; const broker: ConnectedAppBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/shared-gated-mode.integration.test.ts b/server/tests/shared-gated-mode.integration.test.ts index 805ef6576..c7b710341 100644 --- a/server/tests/shared-gated-mode.integration.test.ts +++ b/server/tests/shared-gated-mode.integration.test.ts @@ -24,6 +24,8 @@ const asker = `asker-${suite}`; const VENDOR_ID = `openbot-deployment:acme-${suite}:R`; const broker: ConnectedAppBroker = { + // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: true, listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, From af4b90ffb9fac9ab74eba0d86685435f881affc3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:04:33 +0200 Subject: [PATCH 031/232] feat(arcade): normalizeArcadeError, the vendor's only door into the server Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 45 +++++++++++++++++ server/tests/arcade-adapter-errors.test.ts | 59 ++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 server/src/plugins/arcade-adapter.ts create mode 100644 server/tests/arcade-adapter-errors.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts new file mode 100644 index 000000000..cf35f8ec5 --- /dev/null +++ b/server/src/plugins/arcade-adapter.ts @@ -0,0 +1,45 @@ +import { APIError } from "@arcadeai/arcadejs"; +import type { ArcadeFailure } from "./arcade"; + +/** + * THE VENDOR'S ONLY DOOR INTO THE SERVER. This is the one file under `server/src` that imports + * `@arcadeai/arcadejs`, so a version bump has exactly this file to be read against, the same + * convention `createComposioClient` states for its SDK. It imports the transport (`./arcade`) for + * the `ArcadeFailure` shape; the transport never imports it, because that direction is a cycle and + * `broker.ts` records why. + */ + +/** Deeper than any real wrapping; a bound, so a cyclic `cause` cannot loop. */ +const MAX_CAUSE_DEPTH = 16; + +const textOf = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + +/** + * Walk the `cause` chain to the FIRST `APIError` and read its `status` and the response body's + * `error.name` / `error.message`. The SDK's own fields are `status`, `headers` and `error` (the + * parsed body); nothing on it names the tool, so nothing here looks for one. No `APIError` anywhere + * in the chain answers `null`. + * + * `status` is undefined for `APIConnectionError` and `APIUserAbortError`, which extend `APIError`: + * no response was ever read, so the outcome is uncertain, not refused. + */ +export function normalizeArcadeError(error: unknown): ArcadeFailure | null { + const seen = new Set(); + let current: unknown = error; + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (current === null || typeof current !== "object" || seen.has(current)) return null; + seen.add(current); + if (current instanceof APIError) { + const body = current.error; + const record = body !== null && typeof body === "object" ? (body as Record) : {}; + return { + status: typeof current.status === "number" ? current.status : undefined, + name: textOf(record.name), + message: textOf(record.message), + }; + } + current = (current as { cause?: unknown }).cause; + } + return null; +} diff --git a/server/tests/arcade-adapter-errors.test.ts b/server/tests/arcade-adapter-errors.test.ts new file mode 100644 index 000000000..eb04d686a --- /dev/null +++ b/server/tests/arcade-adapter-errors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { APIConnectionError, APIError } from "@arcadeai/arcadejs"; +import { normalizeArcadeError } from "../src/plugins/arcade-adapter"; + +// The SDK's real shape: constructor(status, error, message, headers); own fields status, +// headers, error. Nothing on it names the tool. +const refusal = () => + new APIError( + 403, + { name: "tool_authorization_required", message: "Authorize Gmail first" }, + undefined, + new Headers(), + ); + +describe("normalizeArcadeError", () => { + test("a realistically-shaped 403 normalizes", () => { + expect(normalizeArcadeError(refusal())).toEqual({ + status: 403, + name: "tool_authorization_required", + message: "Authorize Gmail first", + }); + }); + + test("the same error wrapped in cause still normalizes", () => { + const wrapped = new Error("outer", { cause: new Error("middle", { cause: refusal() }) }); + expect(normalizeArcadeError(wrapped)).toEqual({ + status: 403, + name: "tool_authorization_required", + message: "Authorize Gmail first", + }); + }); + + test("a plain Error answers null", () => { + expect(normalizeArcadeError(new Error("boom"))).toBeNull(); + }); + + test("non-errors answer null", () => { + expect(normalizeArcadeError(undefined)).toBeNull(); + expect(normalizeArcadeError("403")).toBeNull(); + }); + + test("a cyclic cause chain terminates with null", () => { + const a: Error & { cause?: unknown } = new Error("a"); + a.cause = a; + expect(normalizeArcadeError(a)).toBeNull(); + }); + + test("an error with no body leaves name and message undefined", () => { + expect(normalizeArcadeError(new APIError(500, undefined, undefined, new Headers()))).toEqual({ + status: 500, + name: undefined, + message: undefined, + }); + }); + + test("a connection failure keeps status undefined", () => { + expect(normalizeArcadeError(new APIConnectionError({ message: "down" }))?.status).toBeUndefined(); + }); +}); From 7ab7f98cb293d13d4e48600cba50048248895ecb Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:15:30 +0200 Subject: [PATCH 032/232] refactor(arcade): read store.ts's last schemes from the shared record, and arm the construction guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BROKERED_PROFILE moves from store.ts to access.ts, beside TOOLKIT_OF, for that table's reason: access.ts is where a broker's vocabulary is allowed to be spelled and it is the one file brokered-provenance.test.ts exempts, so a scheme literal in store.ts was a second home for the same word. With the record reachable, four sites stop spelling one. brokeredConnectionsFor built both canonical urls as `composio://`. On any other broker that resolved an empty map, so every serverId was undefined, every connection was dropped, and the connected-accounts page rendered empty for somebody holding accounts. Fail-closed and visible, and total. probeBrokeredConnection REFUSES rather than building a url. composioAskAction is Composio's own askAction and no counterpart exists for a second broker, so the composed url sent an Arcade deployment's check to Composio: at a vendor it holds no key for that reports an outage where there was none, and where a key IS present it lands on an unrelated app of the same slug and succeeds, recording a verified check of an account nobody holds. The refusal is the `unreachable` outcome — probe null, so probe_action is left unwritten and the row untouched, and `attempted` carries what would have been spent. KNOWN LIMITATION for docs/plugins/arcade.md (13.2): on a non-Composio broker a connection check is refused with "This deployment brokers its apps through Arcade, and the only connection check this deployment knows how to make is Composio's. Nothing was asked about this connection, so nothing is recorded about it." No Arcade probe is designed in PR 1. The screen-name reservation now derives from BROKERED_PROFILE's keys rather than a second literal, so /admin/plugins/arcade is reserved alongside /admin/plugins/composio once 1.8 adds it. Same argument the id-prefix loop six lines below already makes for its namespace, and each refusal still names the id it refused. shared-use-store.ts repoints schemeOf at BROKERED_PROFILE[provider].scheme. Its local `${provider}://` was correct under 4.5's constraints and was still a second source of truth. The guard gains the scheme-construction pattern and skips comment lines, which is deliberate and narrow: a scheme named in prose is documentation, a scheme named in an expression is a decision. Loosening the pattern until the dozen docblock mentions stopped matching would also stop it matching the thing it is for. The exemption list widens to the three vocabulary modules — access.ts, composio.ts, arcade.ts — each allowed to spell its own scheme. GREEN. The direct-vendor-parser half (/\btoolkitOf\s*\(/) is NOT armed, and the test's docblock records why rather than letting it be quietly dropped: it names routes.ts 617/815/1143/1886, which really do parse every brokered url with Composio's parser and cannot be fixed without wiring the active provider into a routes layer that holds none, and four false positives in shared-use-store.ts where a local closure named toolkitOf IS the dispatch. Arming it today would need a fourth and fifth exemption, which is the escape hatch the guard exists to refuse. No Composio behaviour changes: every composio sentence, url and id is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/access.ts | 33 +++ server/src/plugins/shared-use-store.ts | 17 +- server/src/plugins/store.ts | 105 ++++++--- server/tests/brokered-provenance.test.ts | 98 +++++++- server/tests/store-scheme-sites.test.ts | 286 +++++++++++++++++++++++ 5 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 server/tests/store-scheme-sites.test.ts diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 61b96994c..0ec1e42ec 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -267,6 +267,39 @@ const TOOLKIT_OF: Record string | null> = { arcade: arcadeToolkitOf, }; +/** + * What each broker's rows are minted as: the url scheme, the id namespace and the vendor label. + * + * ONE ROW PER PROVIDER, KEYED ON {@link BrokerProvider}, so a third broker cannot be enabled until + * it says how its rows are spelled. The provenance column is the provider's own name and needs no + * entry here. The id prefix is what prefixes every tool name and what a grant and a policy rule are + * written against, which is why `store.ts` reserves EVERY provider's and not only the active one's: + * a deployment that switches broker must not find a typed server already sitting in the namespace + * the other path mints into. The scheme is reserved the same way one step in — the screen name + * `/admin/plugins/` is a static route on each broker — and that reservation is derived + * from these keys rather than spelled again, because a reservation spelled twice is the one that + * gets added to once. + * + * AND IT LIVES HERE RATHER THAN IN `store.ts`, BESIDE {@link TOOLKIT_OF}, for that table's reason. + * This module is where a broker's vocabulary is allowed to be spelled, and it is the one file + * `brokered-provenance.test.ts` exempts. A scheme literal in `store.ts` is a second home for the + * same word, and the guard that keeps the first home the only one cannot be absolute while the + * record it reads from sits outside it. `shared-use-store.ts` had to derive its own `${provider}://` + * for exactly as long as this was private here, which is what two homes costs: one table, one + * spelling, read by everybody. + */ +export const BROKERED_PROFILE: Record< + BrokerProvider, + { scheme: string; idPrefix: string; vendor: string } +> = { + composio: { + scheme: "composio://", + idPrefix: "composio-", + vendor: "Composio", + }, + arcade: { scheme: "arcade://", idPrefix: "arcade-", vendor: "Arcade" }, +}; + /** * {@link TOOLKIT_OF}, reachable. A function so a caller cannot index the table with a widened * string, and so the table stays private to the one module that argues for it: a second dispatch diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts index 07f3edf8d..978418190 100644 --- a/server/src/plugins/shared-use-store.ts +++ b/server/src/plugins/shared-use-store.ts @@ -20,7 +20,7 @@ import { teamBotPublications, users, } from "../db/schema"; -import { type BrokerProvider, toolkitOfFor } from "./access"; +import { BROKERED_PROFILE, type BrokerProvider, toolkitOfFor } from "./access"; import { type ActorFacts, type ApprovalMember, @@ -74,12 +74,17 @@ const sameApproval = (left: SharedUseApproval, right: SharedUseApproval) => covers(left, right) && covers(right, left); /** - * The url scheme a broker's apps live at. A broker's scheme is its provider's name, `://`, - * the same identity the `provenance` column already leans on (see {@link BrokerProvider}), so it is - * derived from the provider rather than spelled as a literal a third broker would have to be - * remembered in. + * The url scheme a broker's apps live at, read from the one record that holds it. + * + * DERIVED HERE AS `${provider}://` UNTIL {@link BROKERED_PROFILE} BECAME REACHABLE. That derivation + * was right about every scheme there is and still a second source of truth: the store mints its rows + * from the record, this read them back from a rule, and nothing held the two in step beyond the + * happy fact that a broker's scheme is its own name. A third broker that spelled its scheme any + * other way would have had its rows written at one address and looked for at another, silently, in + * the gate that decides who may use a shared account. One table, one spelling. */ -const schemeOf = (provider: BrokerProvider) => `${provider}://`; +const schemeOf = (provider: BrokerProvider) => + BROKERED_PROFILE[provider].scheme; /** * `provider` IS REQUIRED, with no default, for the reason `PluginStoreOptions.brokerProvider` is: diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index b773500f0..654749e09 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -46,6 +46,7 @@ import { import type { CheckPrivateShare } from "../proactive/private-share"; import { accessFor, + BROKERED_PROFILE, type BrokerProvider, type ServerAccess, ServerUnresolvableError, @@ -1108,28 +1109,22 @@ const CLIENT_REREGISTRATION_BACKOFF_MS = 5 * 60_000; const IDENTITY_ACTION = /(_GET_ME|_PROFILE|_CURRENT_USER|_USER_INFO|_WHOAMI)$/; /** - * What each broker's rows are minted as: the url scheme, the id namespace and the vendor label. - * - * ONE ROW PER PROVIDER, KEYED ON {@link BrokerProvider}, so a third broker cannot be enabled until - * it says how its rows are spelled. The provenance column is the provider's own name and needs no - * entry here. The id prefix is what prefixes every tool name and what a grant and a policy rule are - * written against, which is why {@link BROKERED_ID_PREFIXES} reserves every provider's, not only the - * active one's: a deployment that switches broker must not find a typed server already sitting in - * the namespace the other path mints into. + * Every broker's id namespace, reserved together. See {@link BROKERED_PROFILE} in `./access` for + * what a profile holds and why it lives there; this is only the list this file reserves off it. */ -const BROKERED_PROFILE: Record< - BrokerProvider, - { scheme: string; idPrefix: string; vendor: string } -> = { - composio: { - scheme: "composio://", - idPrefix: "composio-", - vendor: "Composio", - }, - arcade: { scheme: "arcade://", idPrefix: "arcade-", vendor: "Arcade" }, -}; const BROKERED_ID_PREFIXES = Object.values(BROKERED_PROFILE); +/** + * Every broker's screen name, reserved together, for the reason {@link BROKERED_ID_PREFIXES} is. + * + * THE KEYS, NOT A SECOND LITERAL LIST. `/admin/plugins/composio` is the app directory's own page and + * `/admin/plugins/arcade` is the Arcade one, and a static route is matched ahead of + * `/admin/plugins/$key` on both — so a server sitting at either id would be listed and then open + * somebody else's page instead of its own. Spelled as its own list, this reservation would be the + * one somebody adds a broker to {@link BROKERED_PROFILE} without adding to. + */ +const BROKERED_SCREEN_NAMES = Object.entries(BROKERED_PROFILE); + /** What a vendor's token endpoint gave back for a refresh token. */ export type AccessToken = { accessToken: string; @@ -3000,15 +2995,24 @@ export function createPluginStore(options: PluginStoreOptions) { /* * Nor may it take the name of a screen. `/admin/plugins/composio` is a static route — the app - * directory's own page — and a static route is matched ahead of `/admin/plugins/$key`, so a - * server sitting at this id would be listed and then open somebody else's page instead of its - * own. Brokered servers are `composio-` and no catalogue entry is called this, which - * leaves a hand-typed id as the only way to reach it. + * directory's own page — and `/admin/plugins/arcade` is the Arcade equivalent, and a static + * route is matched ahead of `/admin/plugins/$key`, so a server sitting at either id would be + * listed and then open somebody else's page instead of its own. Brokered servers are + * `-` and no catalogue entry is called either of these, which leaves a + * hand-typed id as the only way to reach them. + * + * BOTH, ON WHICHEVER BROKER THIS DEPLOYMENT RUNS, and derived from + * {@link BROKERED_SCREEN_NAMES} rather than from a literal — the same argument the id-prefix + * loop six lines below makes for its own namespace: the screen is there to be collided with + * whether or not this deployment brokers through it today, and a reservation spelled twice is + * the one that gets added to once. Each refusal still names the id it refused. */ - if (input.id === "composio") { - throw new CustomServerRefusedError( - "composio is the name of this deployment's own Composio screen, so a server added there could never be opened. Choose another.", - ); + for (const [screen, { vendor }] of BROKERED_SCREEN_NAMES) { + if (input.id === screen) { + throw new CustomServerRefusedError( + `${screen} is the name of this deployment's own ${vendor} screen, so a server added there could never be opened. Choose another.`, + ); + } } /* @@ -5451,13 +5455,21 @@ export function createPluginStore(options: PluginStoreOptions) { * join answered and what the missing `serverId` below drops. A person can hold an account at * an app this deployment has since removed, and the settings page has no row to draw for it. */ + /* + * AT THE ACTIVE BROKER'S SCHEME, which is the one {@link brokeredAppRow} resolves every other + * url against. Spelled `composio://` here, this read answered an empty map on any other + * broker — every `serverId` undefined, every connection dropped, and the connected-accounts + * page drawn empty for somebody holding accounts. Fail-closed and visible, and total. + */ + const canonical = (toolkit: string) => + `${BROKERED_PROFILE[brokerProvider].scheme}${toolkit}`; const named = await brokeredAppRowsAt( - connections.map((row) => `composio://${row.toolkit}`), + connections.map((row) => canonical(row.toolkit)), ); const rows = connections .flatMap((row) => { - const serverId = named.get(`composio://${row.toolkit}`)?.id; + const serverId = named.get(canonical(row.toolkit))?.id; return serverId === undefined ? [] : [{ ...row, serverId }]; }) // By server id, as the join's own `order by` was, so this read and `connectionsFor` hand the @@ -6056,9 +6068,42 @@ export function createPluginStore(options: PluginStoreOptions) { * Composio transport strips before anything reaches the vendor and asserts that it did, so * what Composio is handed is the action and an empty argument object. */ + /* + * AND ON ANY OTHER BROKER THIS REFUSES RATHER THAN DIALS, because `composioAskAction` is + * Composio's own `askAction` and no counterpart has been written for a second broker. + * + * CRITERION. A deployment whose broker is not Composio gets a stated refusal here, and no + * request leaves this process. + * + * REASON. The url this used to compose was `composio://` whatever the broker was, so + * an Arcade deployment's check was sent to Composio with a Composio-shaped url. On a + * deployment holding no Composio key that fails at a vendor it has no business talking to and + * the person is told their connection could not be checked — an outage reported where there + * was none. Where a Composio key IS present it is worse: the slug is a plain app name, so the + * call lands on an UNRELATED app of the same name at the wrong vendor and succeeds, and the + * row records a verified check of an account nobody holds. + * + * `unreachable` IS THE HONEST OUTCOME, not `nothing`. Nothing was learned about the key and + * nothing is recorded about it — `probe` is null, so {@link _ProbeOutcomeLeaves} leaves the + * row untouched and `probe_action` unwritten — and `attempted` is what WOULD have been spent, + * which is the fact about this deployment's intent that field exists to carry. The sentence + * names the state rather than blaming the connection, because the gap is ours. + * + * TODO: delete this once a probe is designed for the second broker. Until then a refusal that + * says so is the only answer that is not a claim about somebody's account. + */ + if (brokerProvider !== "composio") { + return { + outcome: "unreachable", + probe: null, + attempted: candidate.name, + sentence: `This deployment brokers its apps through ${BROKERED_PROFILE[brokerProvider].vendor}, and the only connection check this deployment knows how to make is Composio's. Nothing was asked about this connection, so nothing is recorded about it.`, + }; + } + const { result, answered } = await composioAskAction( { - url: `composio://${input.toolkit}`, + url: `${BROKERED_PROFILE.composio.scheme}${input.toolkit}`, actorId: input.account.vendorUserId, // Spread rather than passed as `undefined`, so "any account of theirs" reaches the wire // as a body with no such key. See {@link ComposioActions.execute}. diff --git a/server/tests/brokered-provenance.test.ts b/server/tests/brokered-provenance.test.ts index 4a23a1941..804920ce6 100644 --- a/server/tests/brokered-provenance.test.ts +++ b/server/tests/brokered-provenance.test.ts @@ -1,11 +1,48 @@ // server/tests/brokered-provenance.test.ts /** - * Guard: no plugin module outside `access.ts` may hard-code a brokered vendor - * name ("composio" / "arcade") in a provenance or provider comparison. + * Guard: no plugin module outside the three VOCABULARY modules may spell a brokered vendor's name — + * in a provenance or provider comparison, or in a url scheme it builds. * - * `access.ts` is the one exempt file because it is where the vocabulary lives. + * `access.ts`, `composio.ts` and `arcade.ts` are the exempt files. `access.ts` is where the shared + * vocabulary lives — {@link BrokerProvider}, {@link brokeredProvenance}, `TOOLKIT_OF` and + * `BROKERED_PROFILE`, which is why the scheme literals are there and not in `store.ts`. The other + * two are each one broker's own module, and a module whose whole job is to speak one vendor's + * protocol has to be able to spell that vendor's scheme and define that vendor's parser. Neither + * trips the provenance patterns today, and neither is exempt from anything it does not need: + * widening the list is what makes the scheme pattern absolute everywhere else, instead of a pattern + * with a per-line escape hatch — and an escape hatch in a guard is the thing that gets used the + * second time without the argument being made again. * - * This guard is green and must stay green; `access.ts` is the only exemption. + * COMMENT LINES ARE SKIPPED, DELIBERATELY AND NARROWLY. A line whose trimmed start is `*`, `//` or + * `/*` is not scanned. `store.ts` alone carries about a dozen `composio://` mentions inside + * docblocks — "a row called `gmail` at `composio://slack`" and the like — which are explanation of + * why a rule exists, not an instance of breaking it. A SCHEME NAMED IN PROSE IS DOCUMENTATION; A + * SCHEME NAMED IN AN EXPRESSION IS A DECISION. The skip is on the line's syntactic position and + * nothing else, so it cannot grow: the alternative — loosening the pattern until the docblocks stop + * matching — would also stop it matching the thing it is for, which is a string carrying a scheme. + * + * The offenders are collected into a list and compared against `[]` rather than asserted one file at + * a time, so a new site has to be argued for here rather than quietly allowed. + * + * THE OTHER HALF OF THIS GUARD IS NOT ARMED YET, and this is the record of why. A direct call to a + * vendor module's own parser — `toolkitOf(url)` rather than `toolkitOfFor(provider, url)` — is the + * same mistake as a spelled scheme, and the pattern for it is `/\btoolkitOf\s*\(/`. Run today it + * names eight lines: + * + * `routes.ts` 617, 815, 1143, 1886 — genuine. That module imports `toolkitOf` from `./composio` + * and parses every brokered url with Composio's parser, so on any other broker each of these + * answers null and the route refuses an app that is perfectly well connected. It cannot be fixed + * here: `routes.ts` holds no provider at all (`grep -n 'provider' server/src/plugins/routes.ts` + * is empty), so routing these through `toolkitOfFor` means wiring the active broker into the + * routes layer, which is a change of its own. + * + * `shared-use-store.ts` 154, 427, 484, 492 — false positives. Those call a LOCAL closure that + * happens to be named `toolkitOf` and whose body is `toolkitOfFor(provider, url)`: the dispatch + * itself, read by the pattern as the thing it dispatches to. + * + * Arming it now would mean exempting a fourth and fifth module, which is exactly the hatch the + * paragraph above refuses. So it waits for the routes layer to learn its provider and for that + * closure to be renamed, and this comment is what stops the pattern being quietly dropped instead. */ import { expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; @@ -13,6 +50,9 @@ import { join } from "node:path"; const PLUGINS_DIR = join(import.meta.dir, "../src/plugins"); +/** The modules allowed to speak a broker's vocabulary. Every other plugin module asks them. */ +const VOCABULARY = new Set(["access.ts", "composio.ts", "arcade.ts"]); + const PATTERNS: RegExp[] = [ /provenance\s*[!=]==\s*"(composio|arcade)"/, /brokeredConnections\.provider\s*,\s*"(composio|arcade)"/, @@ -21,16 +61,29 @@ const PATTERNS: RegExp[] = [ // `provenance === "composio"` pattern above. A guard catching one spelling of a mistake and not // the other is worse than none, because it is read as coverage. /(mcpServers|\w+)\.provenance\s*,\s*"(composio|arcade)"/, + // A scheme spelled into a string — the construction half. `composio://${toolkit}` in `store.ts` + // is what dropped every Arcade connection off the settings page and what sent an Arcade + // deployment's probe to Composio; the scheme comes out of `BROKERED_PROFILE` now. + /["'`](composio|arcade):\/\//, ]; -test("no plugin module hard-codes a brokered vendor name outside access.ts", () => { +/** A line of prose rather than a line of code. See the docblock: the skip is narrow on purpose. */ +function isComment(line: string): boolean { + const start = line.trimStart(); + return ( + start.startsWith("*") || start.startsWith("//") || start.startsWith("/*") + ); +} + +test("no plugin module hard-codes a brokered vendor name outside the vocabulary modules", () => { const offenders: string[] = []; const files = readdirSync(PLUGINS_DIR) - .filter((f) => f.endsWith(".ts") && f !== "access.ts") + .filter((f) => f.endsWith(".ts") && !VOCABULARY.has(f)) .sort(); for (const file of files) { const lines = readFileSync(join(PLUGINS_DIR, file), "utf8").split("\n"); lines.forEach((line, i) => { + if (isComment(line)) return; if (PATTERNS.some((re) => re.test(line))) { offenders.push(`${file}:${i + 1}`); } @@ -38,3 +91,36 @@ test("no plugin module hard-codes a brokered vendor name outside access.ts", () } expect(offenders).toEqual([]); }); + +/** + * And the comment skip does not also skip code, which is the one way this guard could go quietly + * useless. Each line below is an offender the scan must still name, including the ones that carry a + * comment — a trailing `//` does not make a line prose, and only a line that STARTS as prose is + * skipped. + */ +test("the comment skip takes prose and leaves every expression", () => { + const code = [ + // Interpolation is spelled by concatenation here only so the linter does not read a template + // placeholder inside a plain string as a mistake; what the pattern sees is the same line. + `const url = \`composio://$\{toolkit}\`;`, + `const url = \`arcade://$\{toolkit}\`; // the scheme`, + ' return { url: "composio://gmail" };', + '\tif (row.provenance === "arcade") return null;', + 'const u = "composio://" + toolkit; /* trailing block */', + ]; + const prose = [ + " * a row called `gmail` at `composio://slack` would decide a Slack confirm", + "// `arcade://Gmail` and `arcade://gmail` are distinct apps.", + "/* `composio://` is the scheme the rows are minted at. */", + ' * `provenance === "composio"` was the comparison this replaced.', + ]; + for (const line of code) { + expect(isComment(line)).toBe(false); + expect(PATTERNS.some((re) => re.test(line))).toBe(true); + } + for (const line of prose) { + // Prose that WOULD match: the skip is what spares it, not a gap in the patterns. + expect(PATTERNS.some((re) => re.test(line))).toBe(true); + expect(isComment(line)).toBe(true); + } +}); diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts new file mode 100644 index 000000000..d22161bca --- /dev/null +++ b/server/tests/store-scheme-sites.test.ts @@ -0,0 +1,286 @@ +/** + * `store.ts`'s last vendor-scheme sites, read through the shared record instead of a literal. + * + * Four of them, and they fail differently: the connections listing drops every row on a non-Composio + * broker, the probe dials a vendor this deployment may hold no key for, the screen-name reservation + * covers only one of the two static routes, and `shared-use-store.ts` spells the scheme a second + * time. Each has its own test below, and each Composio behaviour is asserted unchanged beside it. + */ +import { afterAll, afterEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { inArray, like } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { + brokeredConnections, + mcpServers, + mcpTools, + users, +} from "../src/db/schema"; +import { BROKERED_PROFILE, type BrokerProvider } from "../src/plugins/access"; +import type { BrokerConnection } from "../src/plugins/broker"; +import type { ComposioBroker } from "../src/plugins/composio"; +import { + CustomServerRefusedError, + createPluginStore, +} from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); + +/** Every row this file writes is named `sss…`, so the cleanup can take the whole family. */ +const tag = (name: string) => `sss${name}${suite}`; + +async function clean() { + await database.delete(mcpTools).where(like(mcpTools.serverId, "%sss%")); + await database + .delete(brokeredConnections) + .where(like(brokeredConnections.app, "%sss%")); + await database.delete(mcpServers).where(like(mcpServers.id, "%sss%")); + // The screen names themselves: a refusal that regressed would leave a row behind, and the row + // would then make every later run of those tests fail for the wrong reason. + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, ["composio", "arcade"])); + await database.delete(users).where(like(users.id, "%sss%")); +} +afterEach(clean); +afterAll(async () => { + await clean(); + await database.$client.close(); +}); + +const unasked = (what: string) => async (): Promise => { + throw new Error(`asked the broker to ${what}`); +}; + +function storeFor(provider: BrokerProvider) { + const broker: ComposioBroker = { + grantIsLocal: true, + listApps: unasked("list"), + ensureAuthConfig: async () => "created", + deleteAuthConfig: unasked("delete"), + authorize: unasked("authorize"), + isConnected: unasked("check"), + revoke: unasked("revoke"), + accountName: async () => null, + }; + return createPluginStore({ + brokerProvider: provider, + database, + auditStore: createAuditStore(database), + credentials: { + readSecret: async () => null, + create: async () => { + throw new Error("vault"); + }, + updateSecret: async () => { + throw new Error("vault"); + }, + revoke: async () => { + throw new Error("vault"); + }, + }, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + }); +} + +const consent: BrokerConnection = { kind: "consent" }; + +/** The app slug each broker's rows are written at here; Arcade's case is never normalised. */ +const APP: Record = { + composio: tag("gmail"), + arcade: tag("Gmail"), +}; + +async function seedApp(provider: BrokerProvider, serverId: string) { + await database.insert(mcpServers).values({ + id: serverId, + title: "Gmail", + vendor: BROKERED_PROFILE[provider].vendor, + url: `${BROKERED_PROFILE[provider].scheme}${APP[provider]}`, + provenance: provider, + authScheme: "API_KEY", + }); +} + +async function seedPersonConnection(provider: BrokerProvider, userId: string) { + await database.insert(users).values({ + id: userId, + email: `${userId}@example.test`, + name: "asker", + emailVerified: true, + groups: [], + }); + await database.insert(brokeredConnections).values({ + provider, + app: APP[provider], + holder: "person", + userId, + vendorUserId: userId, + }); +} + +/* (a) The record itself, in the one module allowed to spell a scheme. */ +test("BROKERED_PROFILE lives in access.ts and answers for both brokers", () => { + expect(BROKERED_PROFILE.composio).toEqual({ + scheme: "composio://", + idPrefix: "composio-", + vendor: "Composio", + }); + expect(BROKERED_PROFILE.arcade).toEqual({ + scheme: "arcade://", + idPrefix: "arcade-", + vendor: "Arcade", + }); +}); + +/* (b) The connections listing resolves each app at the ACTIVE broker's scheme. */ +for (const provider of ["composio", "arcade"] as const) { + test(`brokeredConnectionsFor names the ${provider} row at the ${provider} scheme`, async () => { + const serverId = tag(`${provider}row`); + const userId = tag(`${provider}user`); + await seedApp(provider, serverId); + await seedPersonConnection(provider, userId); + + const rows = await storeFor(provider).brokeredConnectionsFor(userId); + expect(rows.map((row) => row.serverId)).toEqual([serverId]); + }); +} + +test("brokeredConnectionsFor drops a connection whose app has no row at that scheme", async () => { + // The other broker's row is not this broker's app: a scheme read off a literal would have found + // it anyway on Composio, and found nothing at all on Arcade. + const userId = tag("crossuser"); + await seedApp("composio", tag("composiorow")); + await seedPersonConnection("arcade", userId); + expect(await storeFor("arcade").brokeredConnectionsFor(userId)).toEqual([]); +}); + +/* (c) The probe refuses on a broker whose check nobody has written, rather than dialling Composio. */ +async function probeOn(provider: BrokerProvider) { + const serverId = tag(`${provider}probe`); + await seedApp(provider, serverId); + await database.insert(mcpTools).values({ + serverId, + name: "GMAIL_GET_ME", + description: "Who this key belongs to", + inputSchema: {}, + effect: "read", + destructive: false, + version: "20260101_00", + }); + return await storeFor(provider).probeBrokeredConnection({ + toolkit: APP[provider], + account: { holder: "person", userId: tag("probeuser"), vendorUserId: "v" }, + }); +} + +test("the probe refuses on Arcade rather than dialling Composio", async () => { + const probed = await probeOn("arcade"); + expect(probed).toEqual({ + outcome: "unreachable", + probe: null, + attempted: "GMAIL_GET_ME", + sentence: + "This deployment brokers its apps through Arcade, and the only connection check this deployment knows how to make is Composio's. Nothing was asked about this connection, so nothing is recorded about it.", + }); +}); + +test("the probe on Composio still reaches the Composio transport", async () => { + const probed = await probeOn("composio"); + // No client is installed in a test process, so the transport refuses before it dials — which is + // the point: the sentence is the TRANSPORT'S, not the refusal above. + expect(probed.outcome).toBe("unreachable"); + expect(probed).toHaveProperty("attempted", "GMAIL_GET_ME"); + expect((probed as { sentence: string }).sentence).toContain( + "Composio is not configured for this deployment", + ); +}); + +/* (d) Both brokers' screen names are reserved, each refused in its own sentence. */ +for (const [id, vendor] of [ + ["composio", "Composio"], + ["arcade", "Arcade"], +] as const) { + for (const provider of ["composio", "arcade"] as const) { + test(`a ${provider} store refuses a server hand-typed as ${id}`, async () => { + await expect( + storeFor(provider).addCustomServer({ + id, + title: "Collector", + url: "https://collector.example/mcp", + by: "admin@example.com", + }), + ).rejects.toThrow( + new CustomServerRefusedError( + `${id} is the name of this deployment's own ${vendor} screen, so a server added there could never be opened. Choose another.`, + ).message, + ); + const [found] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(inArray(mcpServers.id, ["composio", "arcade"])); + expect(found).toBeUndefined(); + }); + } +} + +test("the brokered id-prefix refusal is untouched by the screen-name one", async () => { + // Six lines apart and about different things: `arcade` is a screen, `arcade-x` is a namespace. + await expect( + storeFor("composio").addCustomServer({ + id: "arcade-x", + title: "Collector", + url: "https://collector.example/mcp", + by: "admin@example.com", + }), + ).rejects.toThrow( + "Names beginning arcade- belong to the Arcade apps this deployment enables", + ); +}); + +/* (e) One table, one spelling: `shared-use-store.ts` reads the record rather than rebuilding it. */ +test("shared-use-store derives its scheme from BROKERED_PROFILE and nowhere else", () => { + const source = readFileSync( + join(import.meta.dir, "../src/plugins/shared-use-store.ts"), + "utf8", + ); + const code = source + .split("\n") + .filter((line) => { + const start = line.trimStart(); + return !( + start.startsWith("*") || + start.startsWith("//") || + start.startsWith("/*") + ); + }) + .join("\n"); + expect(code).toContain("BROKERED_PROFILE[provider].scheme"); + // The local derivation 4.5 could not avoid, now that the record is reachable. + expect(code).not.toMatch(/\$\{provider\}:\/\//); +}); + +/* And `addBrokeredApp` keeps writing at the record's scheme, which is where the record came from. */ +for (const provider of ["composio", "arcade"] as const) { + test(`addBrokeredApp still writes a ${provider} row at the record's scheme`, async () => { + const record = await storeFor(provider).addBrokeredApp({ + slug: APP[provider], + title: "Gmail", + by: "admin@example.com", + connection: consent, + }); + expect(record.url).toBe( + `${BROKERED_PROFILE[provider].scheme}${APP[provider]}`, + ); + expect(record.id).toBe( + `${BROKERED_PROFILE[provider].idPrefix}${APP[provider]}`, + ); + }); +} From fd3abf971de4d300c79facadc82dee7116aea1b1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:16:46 +0200 Subject: [PATCH 033/232] style(arcade): apply the repo formatter to the adapter error normalizer Line wrapping only, produced by `bun run format`. No content change: the normalizer and its tests are byte-equivalent after reflow. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 8 ++++++-- server/tests/arcade-adapter-errors.test.ts | 14 +++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index cf35f8ec5..ceaa4d59c 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -28,11 +28,15 @@ export function normalizeArcadeError(error: unknown): ArcadeFailure | null { const seen = new Set(); let current: unknown = error; for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { - if (current === null || typeof current !== "object" || seen.has(current)) return null; + if (current === null || typeof current !== "object" || seen.has(current)) + return null; seen.add(current); if (current instanceof APIError) { const body = current.error; - const record = body !== null && typeof body === "object" ? (body as Record) : {}; + const record = + body !== null && typeof body === "object" + ? (body as Record) + : {}; return { status: typeof current.status === "number" ? current.status : undefined, name: textOf(record.name), diff --git a/server/tests/arcade-adapter-errors.test.ts b/server/tests/arcade-adapter-errors.test.ts index eb04d686a..4c937fac6 100644 --- a/server/tests/arcade-adapter-errors.test.ts +++ b/server/tests/arcade-adapter-errors.test.ts @@ -22,7 +22,9 @@ describe("normalizeArcadeError", () => { }); test("the same error wrapped in cause still normalizes", () => { - const wrapped = new Error("outer", { cause: new Error("middle", { cause: refusal() }) }); + const wrapped = new Error("outer", { + cause: new Error("middle", { cause: refusal() }), + }); expect(normalizeArcadeError(wrapped)).toEqual({ status: 403, name: "tool_authorization_required", @@ -46,7 +48,11 @@ describe("normalizeArcadeError", () => { }); test("an error with no body leaves name and message undefined", () => { - expect(normalizeArcadeError(new APIError(500, undefined, undefined, new Headers()))).toEqual({ + expect( + normalizeArcadeError( + new APIError(500, undefined, undefined, new Headers()), + ), + ).toEqual({ status: 500, name: undefined, message: undefined, @@ -54,6 +60,8 @@ describe("normalizeArcadeError", () => { }); test("a connection failure keeps status undefined", () => { - expect(normalizeArcadeError(new APIConnectionError({ message: "down" }))?.status).toBeUndefined(); + expect( + normalizeArcadeError(new APIConnectionError({ message: "down" }))?.status, + ).toBeUndefined(); }); }); From c213159ab7c8e713f3f37a500c764979d13934b3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:30:21 +0200 Subject: [PATCH 034/232] fix(arcade): return output.value only, never the execute envelope `ArcadeActions.execute` was typed `Promise`, so `callTool` stringified the WHOLE `ExecuteToolResponse` into `McpCallResult.text`. A 200 whose `output.authorization` is populated carries the same `AuthorizationResponse` the 403 does: a bearer authorization url and a `context.token` that is a live OAuth access token for the end user's account. That text is returned to the model and copied into the audit payload, which is exactly what this function's docblock promises never happens. The promise only ever held for the refusal strings; the success path was never covered and no test exercised it. The same three lines never read `success` or `output.error`, so an Arcade-reported tool failure reached the model as `isError: false`, with the vendor's `developer_message` and `stacktrace` embedded. Narrow `execute`'s return type so the envelope must be destructured, refuse on `output.authorization` instead of returning it, serialize `output.value` alone, and fail with this deployment's own sentence. Also replace the double cast through `record` in the catch arm with an explicit `arcadeFailure` shape test: `record` normalizes nothing and walks no `cause`, so the cast let any object stand in for a contract nothing yet produces. What `isAuthorizationRequired` keys on is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade.ts | 58 ++++++++++++++++++++++++++---- server/tests/arcade-errors.test.ts | 55 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 391ea94db..afa4334f6 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -81,7 +81,14 @@ export type ArcadeActions = { tool_name: string; input: Record; user_id: string; - }): Promise; + }): Promise<{ + success?: boolean; + output?: { + value?: unknown; + error?: { message?: string }; + authorization?: unknown; + }; + }>; }; let installed: ArcadeActions | null = null; @@ -247,6 +254,22 @@ export function isAuthorizationRequired(failure: ArcadeFailure): boolean { return failure.name === "tool_authorization_required"; } +/** + * The thrown value, READ AS A SHAPE rather than cast into one. `record` is a plain-object coercion + * that normalizes nothing and walks no `cause`, so a cast through it would let any object at all + * stand in for a normalized failure. Only a `name` string is required, since that is the one field + * `isAuthorizationRequired` keys on; the rest are read as-is or left undefined. + */ +function arcadeFailure(thrown: unknown): ArcadeFailure | null { + const raw = record(thrown); + if (!raw || typeof raw.name !== "string") return null; + return { + status: typeof raw.status === "number" ? raw.status : undefined, + name: raw.name, + message: typeof raw.message === "string" ? raw.message : undefined, + }; +} + const failureResult = (text: string): McpCallResult => ({ text, isError: true, @@ -259,12 +282,22 @@ const failureResult = (text: string): McpCallResult => ({ * THE ACTION IN THE REFUSAL COMES FROM THE CALL SITE, never from the error: `APIError` in * `@arcadeai/arcadejs@2.4.1` carries `status`, `headers` and `error` and nothing naming the tool. * - * NO URL IS EVER IN THE SENTENCE. The 403 envelope's `authorization.url` is a bearer capability: + * NO URL IS EVER IN THE SENTENCE, ON ANY PATH. The `authorization.url` is a bearer capability: * whoever opens it attaches an account to the identity the flow was minted for. It is never * returned to a model, logged, put in an error body or written to an audit row. The refusal names * the Settings page, where the link is minted by an authenticated request whose actor is known. * - * NOTHING THE VENDOR SAID IS REPEATED either: the ordinary failure is this deployment's sentence. + * THE SUCCESS PATH IS COVERED BY THAT SAME PROMISE, and this is where it is easiest to break. A 200 + * is not a safe envelope: `ExecuteToolResponse.output.authorization` is the SAME + * `AuthorizationResponse` the 403 carries, with the url and a `context.token` that is a LIVE OAUTH + * ACCESS TOKEN for the end user's account. `output.value` IS THE ONLY PART OF THE ENVELOPE THAT MAY + * CROSS INTO `text`; the envelope is never stringified whole, because `text` is returned to a model + * and copied into the audit row. An answer carrying an `authorization` at all is the refusal, not a + * result: it means the call was not run as an authorized actor. + * + * NOTHING THE VENDOR SAID IS REPEATED either: the ordinary failure is this deployment's sentence, + * and `output.error` is read only as a BOOLEAN — its `message`, `developer_message` and + * `stacktrace` are the vendor's words and stay out of `text`. */ export async function callTool( connection: { url: string; actorId?: string }, @@ -287,11 +320,22 @@ export async function callTool( input: args, user_id: connection.actorId, }); - const text = typeof answer === "string" ? answer : JSON.stringify(answer); - return { text: text ?? "", isError: false, truncated: false }; + if (answer.output?.authorization) { + return failureResult( + `${toolName} has not been authorized on this account yet. Authorize it in Settings → Connected accounts and ask again.`, + ); + } + if (answer.success === false || answer.output?.error) { + return failureResult(`Arcade could not run ${toolName}.`); + } + return { + text: JSON.stringify(answer.output?.value ?? null, null, 2), + isError: false, + truncated: false, + }; } catch (thrown) { - const failure = record(thrown) as Partial | null; - if (failure && isAuthorizationRequired(failure as ArcadeFailure)) { + const failure = arcadeFailure(thrown); + if (failure && isAuthorizationRequired(failure)) { return failureResult( `${toolName} has not been authorized on this account yet. Authorize it in Settings → Connected accounts and ask again.`, ); diff --git a/server/tests/arcade-errors.test.ts b/server/tests/arcade-errors.test.ts index e8ecd2440..09a9e6087 100644 --- a/server/tests/arcade-errors.test.ts +++ b/server/tests/arcade-errors.test.ts @@ -125,3 +125,58 @@ describe("listTools failures", () => { expect(error!.cause).toBe(vendor); }); }); + +function answering(answer: unknown) { + useArcadeClient({ + listTools: async () => { + throw new Error("not listing"); + }, + execute: async () => answer as never, + }); +} + +describe("callTool successes", () => { + test("a 200 carrying an authorization envelope leaks neither the url nor the token", async () => { + answering({ + success: true, + output: { + value: { ok: 1 }, + authorization: { + url: "https://example.test/auth?state=SECRET", + context: { token: "tok-LEAKME" }, + }, + }, + }); + const result = await callTool(connection, "Gmail.SendEmail", {}); + expect(result.text).not.toContain("http"); + expect(result.text).not.toContain("SECRET"); + expect(result.text).not.toContain("LEAKME"); + expect(result.text).toBe( + "Gmail.SendEmail has not been authorized on this account yet. Authorize it in Settings → Connected accounts and ask again.", + ); + expect(result.isError).toBe(true); + }); + + test("an Arcade-reported failure is an error, in this deployment's words", async () => { + answering({ success: false, output: { error: { message: "LEAKME" } } }); + const result = await callTool(connection, "Gmail.SendEmail", {}); + expect(result.isError).toBe(true); + expect(result.text).toBe("Arcade could not run Gmail.SendEmail."); + expect(result.text).not.toContain("LEAKME"); + }); + + test("a plain success returns output.value and nothing else from the envelope", async () => { + answering({ + success: true, + execution_id: "exec-LEAKME", + duration: 12.5, + output: { value: { rows: [1, 2] } }, + }); + const result = await callTool(connection, "Gmail.SendEmail", {}); + expect(result.isError).toBe(false); + expect(result.text).toBe(JSON.stringify({ rows: [1, 2] }, null, 2)); + expect(result.text).not.toContain("execution_id"); + expect(result.text).not.toContain("exec-LEAKME"); + expect(result.text).not.toContain("duration"); + }); +}); From cfe049808ec0de4b69bfbee751c18b56ee40ba91 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:37:26 +0200 Subject: [PATCH 035/232] refactor(arcade): rename shared-use-store's dispatch closure toolkitOf to appOfUrl The closure is toolkitOfFor(provider, url), the provider dispatch, and its name read as the vendor parser it dispatches to. It was a false positive for the vendor-name guard's /\btoolkitOf\s*\(/ pattern and the last one outside the exempt vocabulary modules. appOfUrl matches this file's vocabulary (rowsOfApp, appIdOf, sharedAppsHeldBy). Name and prose only; no behaviour change. grep -n '\btoolkitOf\s*(' server/src/plugins/shared-use-store.ts returns nothing. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/shared-use-store.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts index 978418190..1ac9f9609 100644 --- a/server/src/plugins/shared-use-store.ts +++ b/server/src/plugins/shared-use-store.ts @@ -99,7 +99,7 @@ export function createSharedUseStore( ) { const roles = createRoleRepository(database); const scheme = schemeOf(provider); - const toolkitOf = (url: string) => toolkitOfFor(provider, url); + const appOfUrl = (url: string) => toolkitOfFor(provider, url); const canonicalUrlOf = (toolkit: string) => `${scheme}${toolkit}`; /* @@ -112,8 +112,8 @@ export function createSharedUseStore( * than imported because the store's helper is a closure inside `createStore`, and this store is * built from the database alone. * - * "AT THE URL" MEANS THE CANONICAL ONE, `${scheme}${toolkitOf(url)}`, NOT THE DIALLED ROW'S OWN - * url string. `toolkitOf` (the active provider's own parser, via `toolkitOfFor`) trims whitespace and a trailing slash before it reads + * "AT THE URL" MEANS THE CANONICAL ONE, `${scheme}${appOfUrl(url)}`, NOT THE DIALLED ROW'S OWN + * url string. `appOfUrl` (the active provider's own parser, via `toolkitOfFor`) trims whitespace and a trailing slash before it reads * the slug, so a row stored as `github/` or `github ` names the toolkit * `github` exactly as `github` does — and the store's own gate (`brokeredAppRow`, * `accountRefFor`) only ever looks rows up at the trimmed, canonical address. Grouping by the raw @@ -122,9 +122,9 @@ export function createSharedUseStore( * else. So THIS is "the same rule" the comment above claims — same `order by`, same address the * gate itself resolves to. * - * A row whose url is not at the provider's scheme at all, or whose slug `toolkitOf` cannot parse, is an app + * A row whose url is not at the provider's scheme at all, or whose slug `appOfUrl` cannot parse, is an app * of its own and is the only row in its answer — the same two cases the store's own gate lets - * stand rather than refuses (`accountRefFor` reads `toolkitOf` only for a row provenanced at the active provider, + * stand rather than refuses (`accountRefFor` reads `appOfUrl` only for a row provenanced at the active provider, * and a null result there is read as "not brokered", not as a refusal). * * NO ROW AT THE CANONICAL URL is answered by falling back to the dialled `serverId` itself, which @@ -148,7 +148,7 @@ export function createSharedUseStore( .where(eq(mcpServers.id, serverId)) .limit(1); const url = row?.url ?? null; - const toolkit = url ? toolkitOf(url) : null; + const toolkit = url ? appOfUrl(url) : null; if (!toolkit) return { appId: serverId, url, ids: [serverId] }; const canonical = canonicalUrlOf(toolkit); const siblings = await database @@ -397,7 +397,7 @@ export function createSharedUseStore( * url to its answering row, and the mode, the title and the id returned are that row's. A Bot * holding tools through two rows at one url holds one app and is listed once. * - * "ITS URL" IS THE CANONICAL ONE A GRANTED ROW RESOLVES TO THROUGH `toolkitOf`, same as + * "ITS URL" IS THE CANONICAL ONE A GRANTED ROW RESOLVES TO THROUGH `appOfUrl`, same as * `rowsOfApp` above, and not the row's own stored url compared by prefix. A granted row stored as * `github/` is the toolkit `github` exactly as `github` is, and grouping it * by its raw url instead left it unmatched against the answering row at the trimmed address — so @@ -421,7 +421,7 @@ export function createSharedUseStore( .select({ id: mcpServers.id, url: mcpServers.url }) .from(mcpServers) .where(inArray(mcpServers.id, serverIds)); - const toolkitOfRow = (url: string | null) => (url ? toolkitOf(url) : null); + const toolkitOfRow = (url: string | null) => (url ? appOfUrl(url) : null); const canonicalUrls = [ ...new Set( granted @@ -468,17 +468,17 @@ export function createSharedUseStore( * to contain `_` and `%`, which a LIKE would read as wildcards and match a neighbouring app's * grants by. * - * "ANY ROW AT ITS URL" HAS TO MEAN EVERY ROW `toolkitOf` RESOLVES TO THE SAME TOOLKIT, NOT ONLY + * "ANY ROW AT ITS URL" HAS TO MEAN EVERY ROW `appOfUrl` RESOLVES TO THE SAME TOOLKIT, NOT ONLY * THE ONES `rowsOfApp` FOUND AT THE EXACT CANONICAL URL. `rowsOfApp`'s `ids` already covers the * canonical rows plus the row actually dialled, but a grant can be made through a THIRD row — * neither the dialled one nor stored at the trimmed address, e.g. `/` sitting * elsewhere in the table — and that row never surfaces from an exact `eq` on the canonical url. So - * every row at the provider's scheme is read once here and matched in JavaScript by `toolkitOf`, which is the + * every row at the provider's scheme is read once here and matched in JavaScript by `appOfUrl`, which is the * one place that defines what "the same toolkit" means. */ async function botsHolding(serverId: string): Promise { const { ids, url } = await rowsOfApp(serverId); - const toolkit = url ? toolkitOf(url) : null; + const toolkit = url ? appOfUrl(url) : null; let allIds = ids; if (toolkit) { const schemeRows = await database @@ -486,7 +486,7 @@ export function createSharedUseStore( .from(mcpServers) .where(sql`${mcpServers.url} like ${`${scheme}%`}`); const matching = schemeRows - .filter((row) => row.url && toolkitOf(row.url) === toolkit) + .filter((row) => row.url && appOfUrl(row.url) === toolkit) .map((row) => row.id); allIds = [...new Set([...ids, ...matching])]; } From 467d00acf5c4b0273742c96eb5bc6c5a5fabd82c Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:38:17 +0200 Subject: [PATCH 036/232] feat(arcade): createArcadeClient, listApps over the full paged listing, execute rejecting with the normalized failure Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 134 ++++++++++++++++++++++++++- server/tests/arcade-adapter.test.ts | 128 +++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 server/tests/arcade-adapter.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index ceaa4d59c..59dc5c19e 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1,5 +1,6 @@ -import { APIError } from "@arcadeai/arcadejs"; -import type { ArcadeFailure } from "./arcade"; +import Arcade, { APIError } from "@arcadeai/arcadejs"; +import type { ArcadeActions, ArcadeFailure } from "./arcade"; +import type { BrokerApp, ConnectedAppBroker } from "./broker"; /** * THE VENDOR'S ONLY DOOR INTO THE SERVER. This is the one file under `server/src` that imports @@ -47,3 +48,132 @@ export function normalizeArcadeError(error: unknown): ArcadeFailure | null { } return null; } + +/** Page size for the catalogue listing. A short page ends it, never `total_count`. */ +const CATALOGUE_PAGE_SIZE = 100; +/** A runaway guard, not a bound. */ +const CATALOGUE_MAX_PAGES = 1000; + +/** + * Every toolkit Arcade publishes, with `actionCount` taken from the FULL PAGED tool listing. + * + * COUNTED, NEVER READ OFF PAGE ONE: `GithubApi` publishes hundreds of actions where `Github` + * publishes 43, so a first page of 100 would be a wrong answer in the picker. Pagination ends on a + * short page; `total_count` is optional on the wire and is used only as a cross-check. + */ +async function listApps(client: Arcade): Promise { + const counts = new Map(); + let seen = 0; + let total: number | undefined; + let ended = false; + for (let page = 0; page < CATALOGUE_MAX_PAGES; page += 1) { + const answer = await client.tools.list({ + limit: CATALOGUE_PAGE_SIZE, + offset: seen, + }); + const items = (answer as { items?: unknown } | null)?.items; + if (!Array.isArray(items)) { + throw new Error( + "Arcade did not answer with a tool list, so its app catalogue could not be read.", + ); + } + for (const item of items) { + const toolkit = ( + item as { toolkit?: { name?: unknown; description?: unknown } } | null + )?.toolkit; + if (!toolkit || typeof toolkit.name !== "string") continue; + const standing = counts.get(toolkit.name); + if (standing) { + standing.actionCount += 1; + continue; + } + counts.set(toolkit.name, { + slug: toolkit.name, + name: toolkit.name, + description: + typeof toolkit.description === "string" ? toolkit.description : "", + logo: null, + categories: [], + actionCount: 1, + connection: { kind: "consent" }, + }); + } + seen += items.length; + const reported = (answer as { total_count?: unknown }).total_count; + if (typeof reported === "number") total = reported; + if (items.length < CATALOGUE_PAGE_SIZE) { + ended = true; + break; + } + } + if (!ended) { + throw new Error( + `Arcade's catalogue never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, + ); + } + if (total !== undefined && seen < total) { + throw new Error( + `Arcade listed ${seen} of ${total} actions, so its app catalogue is incomplete.`, + ); + } + return [...counts.values()]; +} + +const notYet = (task: string, member: string) => () => + Promise.reject(new Error(`${member} is not implemented yet (${task}).`)); + +/** + * The Arcade client, in the shape `createComposioClient` returns. `new Arcade({ apiKey })` opens no + * socket and sends no request, so tests replace the SDK's PROTOTYPE methods before calling this. + * + * `execute` REJECTS WITH THE NORMALIZED FAILURE WHEN THERE IS ONE. `callTool` expects an + * already-normalized, cause-unwrapped `ArcadeFailure`; a raw `APIError`'s `name` is the CLASS name + * through the prototype chain, never the body's `error.name`, so without this the authorize prompt + * is unreachable. `null` means "not an SDK error" and the original is rethrown. + */ +export function createArcadeClient(apiKey: string): { + actions: ArcadeActions; + broker: ConnectedAppBroker; +} { + const client = new Arcade({ apiKey }); + const actions: ArcadeActions = { + listTools: async (q) => + await client.tools.list({ + toolkit: q.toolkit, + limit: q.limit, + offset: q.offset, + }), + execute: async (r) => { + try { + return await client.tools.execute({ + tool_name: r.tool_name, + input: r.input, + user_id: r.user_id, + }); + } catch (error) { + const failure = normalizeArcadeError(error); + if (failure) throw failure; + throw error; + } + }, + }; + const broker: ConnectedAppBroker = { + grantIsLocal: false, + listApps: () => listApps(client), + // The members below are filled by later micro-tasks; they throw until then. + // 5.3: ensureAuthConfig, deleteAuthConfig, authorize + ensureAuthConfig: notYet("micro-task 5.3", "ensureAuthConfig"), + deleteAuthConfig: notYet("micro-task 5.3", "deleteAuthConfig"), + authorize: notYet("micro-task 5.3", "authorize"), + // 6.1: isConnected, revoke, revokeAccount + isConnected: notYet("micro-task 6.1", "isConnected"), + revoke: notYet("micro-task 6.1", "revoke"), + revokeAccount: notYet("micro-task 6.1", "revokeAccount"), + // 6.2: connectionFields, connectWithFields + connectionFields: notYet("micro-task 6.2", "connectionFields"), + connectWithFields: notYet("micro-task 6.2", "connectWithFields"), + // 6.3: accountName + accountName: notYet("micro-task 6.3", "accountName"), + }; + return { actions, broker }; +} diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts new file mode 100644 index 000000000..46c6eac2c --- /dev/null +++ b/server/tests/arcade-adapter.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import Arcade, { APIError } from "@arcadeai/arcadejs"; +import { callTool, useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; + +/** + * Same pattern as `composio-adapter.test.ts` ("the key becoming a vendor"): `new Arcade({ apiKey })` + * opens no socket and sends no request, and its resources' methods live on their classes' + * prototypes, so a prototype replaced BEFORE `createArcadeClient` runs is what the client it builds + * will call. A seed instance is built only to reach the prototype. + */ +type Methods = Record unknown>; +const toolsProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).tools, + ) as Methods; + +const restores: (() => void)[] = []; +function stub( + on: Methods, + name: string, + impl: (...args: unknown[]) => unknown, +) { + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} +afterEach(() => { + while (restores.length) restores.pop()?.(); + useArcadeClient(null); +}); + +const toolOf = (toolkit: string, i: number) => ({ + qualified_name: `${toolkit}.A${i}`, + toolkit: { name: toolkit, description: `${toolkit} things` }, +}); + +describe("createArcadeClient", () => { + test("declares its grant is held by the vendor", () => { + expect(createArcadeClient("never-dialled").broker.grantIsLocal).toBe(false); + }); + + test("constructing it opens no socket and sends no request", () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = ((...args: unknown[]) => { + calls.push(`fetch ${String(args[0])}`); + return Promise.reject(new Error("dialled")); + }) as typeof fetch; + restores.push(() => { + globalThis.fetch = realFetch; + }); + for (const name of ["list", "execute", "get", "authorize"]) + stub(toolsProto(), name, () => { + calls.push(`tools.${name}`); + return Promise.reject(new Error("dialled")); + }); + const proto = Arcade.prototype as unknown as Methods; + for (const name of ["request", "get", "post", "getAPIList"]) + stub(proto, name, () => { + calls.push(`client.${name}`); + return Promise.reject(new Error("dialled")); + }); + createArcadeClient("never-dialled"); + expect(calls).toEqual([]); + }); + + test("listApps counts actions across the FULL paged listing", async () => { + const pages: [string, number][][] = [ + [["GithubApi", 100]], + [["GithubApi", 100]], + [ + ["GithubApi", 40], + ["Github", 10], + ], + ]; + const asked: number[] = []; + stub(toolsProto(), "list", (...args: unknown[]) => { + const q = args[0] as { offset: number; toolkit?: string }; + asked.push(q.offset); + const page = pages[asked.length - 1] ?? []; + const items = page.flatMap(([tk, n]) => + Array.from({ length: n }, (_, i) => toolOf(tk, i)), + ); + return Promise.resolve({ items, total_count: 250, offset: q.offset }); + }); + const apps = await createArcadeClient("never-dialled").broker.listApps(); + expect(asked).toEqual([0, 100, 200]); + expect(apps.find((a) => a.slug === "GithubApi")?.actionCount).toBe(240); + expect(apps.find((a) => a.slug === "Github")?.actionCount).toBe(10); + expect(apps.reduce((n, a) => n + a.actionCount, 0)).toBe(250); + }); + + test("a REAL APIError through execute reaches callTool's authorize sentence", async () => { + stub(toolsProto(), "execute", () => + Promise.reject( + new APIError( + 403, + { name: "tool_authorization_required", message: "Authorize Gmail" }, + undefined, + new Headers(), + ), + ), + ); + const { actions } = createArcadeClient("never-dialled"); + useArcadeClient(actions); + const result = await callTool( + { url: "arcade://Gmail", actorId: "u1" }, + "Gmail.SendEmail", + {}, + ); + expect(result.isError).toBe(true); + expect(result.text).toBe( + "Gmail.SendEmail has not been authorized on this account yet. Authorize it in Settings → Connected accounts and ask again.", + ); + }); + + test("a non-SDK error is rethrown as itself, not as a synthetic failure", async () => { + const boom = new Error("boom"); + stub(toolsProto(), "execute", () => Promise.reject(boom)); + const { actions } = createArcadeClient("never-dialled"); + await expect( + actions.execute({ tool_name: "X.Y", input: {}, user_id: "u" }), + ).rejects.toBe(boom); + }); +}); From fa183ce9fd9b205603f1e29153d00f9fe6a509cc Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:38:47 +0200 Subject: [PATCH 037/232] feat(arcade): register the arcade transport kind, and make BrokerProvider the compiler's claim Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/access.ts | 20 +--------- server/src/plugins/transport.ts | 12 ++++-- .../tests/arcade-transport-registry.test.ts | 39 +++++++++++++++++++ server/tsconfig.json | 3 +- 4 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 server/tests/arcade-transport-registry.test.ts diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index 0ec1e42ec..e896d782d 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -223,7 +223,7 @@ function isBrokerTransport(kind: TransportKind): boolean { * a change to this line, and every `Record` below stops compiling until it is * answered for. */ -export type BrokerProvider = "composio" | "arcade"; +export type BrokerProvider = Extract; /** * Whether a provenance names a broker — the one place that question is asked. @@ -312,22 +312,6 @@ export function toolkitOfFor( return TOOLKIT_OF[provider](url); } -/** - * The transport kind a broker is dialled over, which is the provider's own name. - * - * It is a function rather than the bare value only because {@link TransportKind} does not list - * `arcade` yet — the registry in `./transport` is wired by micro-task 5.1, and until it is, the - * assertion here is what stands in for the union having caught up. Nothing else about the mapping is - * in question: a brokered row is dialled over the transport named after its broker, by construction. - * - * TODO(micro-task 5.1): once `TransportKind` includes `arcade`, delete this and declare - * `BrokerProvider` as `Extract`, which makes the identity the - * compiler's claim rather than this comment's. - */ -function brokerTransport(provider: BrokerProvider): TransportKind { - return provider as TransportKind; -} - /** * A reviewed entry decides for itself; otherwise the row decides — and a row that claims both is * refused rather than resolved. @@ -420,7 +404,7 @@ export function accessFor( */ const provider = row.provenance; return { - transport: brokerTransport(provider), + transport: provider, credential: "brokered", reachedAs: schemeKind(row.authScheme) === "none" || row.accountMode === "shared" diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts index 60a18b333..dbaa5441b 100644 --- a/server/src/plugins/transport.ts +++ b/server/src/plugins/transport.ts @@ -1,3 +1,4 @@ +import * as arcade from "./arcade"; import * as builtinRoutines from "./builtin-routines"; import * as composio from "./composio"; import * as driveRest from "./google-drive-rest"; @@ -119,12 +120,13 @@ export type TransportKind = | "mcp" | "google-drive-rest" | "builtin-routines" - | "composio"; + | "composio" + | "arcade"; /** * The kinds a CATALOGUE ENTRY may name, which is every one except the broker's. * - * CRITERION. `composio` is not writable in a reviewed entry, and the compiler is what says so. + * CRITERION. Neither broker's transport (`composio`, `arcade`) is writable in a reviewed entry, and the compiler is what says so. * * REASON. A brokered row is reached by an app slug read off its url and a per-person connection * looked up by that slug; a catalogue entry has neither, and `accessFor` answers `toolkit: null` @@ -138,13 +140,17 @@ export type TransportKind = * `Exclude` rather than a hand-written second union, so a kind added above is offered to the * catalogue automatically and only the broker stays out. */ -export type CuratedTransportKind = Exclude; +export type CuratedTransportKind = Exclude< + TransportKind, + "composio" | "arcade" +>; const TRANSPORTS: Record = { mcp, "google-drive-rest": driveRest, "builtin-routines": builtinRoutines, composio, + arcade, }; /** diff --git a/server/tests/arcade-transport-registry.test.ts b/server/tests/arcade-transport-registry.test.ts new file mode 100644 index 000000000..fc19cb7db --- /dev/null +++ b/server/tests/arcade-transport-registry.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import * as arcade from "../src/plugins/arcade"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { + CuratedTransportKind, + TransportKind, +} from "../src/plugins/transport"; +import { transportFor } from "../src/plugins/transport"; + +/* + * The type half is checked by `tsc`, not bun: this file is named in `server/tsconfig.json`'s + * `include` (the store-provider-required precedent), because a `@ts-expect-error` under the + * unchecked `server/tests` would assert nothing. Run `bun run typecheck`. + */ +// @ts-expect-error a catalogue entry must not name the arcade broker's transport +const arcadeIsNotCurated: CuratedTransportKind = "arcade"; +// @ts-expect-error nor the composio broker's +const composioIsNotCurated: CuratedTransportKind = "composio"; +const arcadeIsAKind: TransportKind = "arcade"; +// BrokerProvider is the compiler's claim about TransportKind, not a comment's. +const brokerIsAKind: TransportKind = "arcade" satisfies BrokerProvider; + +describe("arcade transport registration", () => { + test("transportFor('arcade') is the arcade module and needs no credential", () => { + const t = transportFor("arcade"); + expect(t).toBe(arcade as unknown as typeof t); + expect(t.listNeedsCredential).toBe(false); + }); + + test("the kind constants are the literals", () => { + const kinds: string[] = [ + arcadeIsNotCurated, + composioIsNotCurated, + arcadeIsAKind, + brokerIsAKind, + ]; + expect(kinds).toEqual(["arcade", "composio", "arcade", "arcade"]); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index a2ca5c446..fffa6aeb2 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -40,6 +40,7 @@ "src", "scripts", "../scripts/composio-smoke.ts", - "tests/store-provider-required.test.ts" + "tests/store-provider-required.test.ts", + "tests/arcade-transport-registry.test.ts" ] } From 042860f82e4f4a0d8c41d011e757536df8dedfe5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:38:56 +0200 Subject: [PATCH 038/232] feat(arcade): the pending_authorizations store surface, one statement per method Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 153 +++++++++++ .../arcade-attempt-store.integration.test.ts | 241 ++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 server/tests/arcade-attempt-store.integration.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 654749e09..30c9caac6 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -38,6 +38,7 @@ import { mcpServers, mcpTools, mcpUserCredentials, + pendingAuthorizations, pluginGrants, skills, skillTools, @@ -1244,6 +1245,29 @@ export type PluginStoreOptions = { deploymentId?: string; }; +/** One in-flight Arcade authorization attempt, as the table stores it. */ +export type PendingAuthorization = typeof pendingAuthorizations.$inferSelect; + +/** Raw `execute` rows are snake_case and carry timestamps as whatever the driver decoded. */ +function pendingAuthorizationFromRow( + row: Record, +): PendingAuthorization { + const when = (v: unknown) => + v === null || v === undefined ? null : new Date(v as string | Date); + return { + actorId: row.actor_id as string, + serverId: row.server_id as string, + action: row.action as string, + holder: row.holder as PendingAuthorization["holder"], + userId: (row.user_id as string | null) ?? null, + vendorUserId: row.vendor_user_id as string, + authId: (row.auth_id as string | null) ?? null, + createdAt: when(row.created_at) as Date, + completedAt: when(row.completed_at), + outcome: (row.outcome as PendingAuthorization["outcome"]) ?? null, + }; +} + /** * The literal recorded on the row, which is the scheme a later call must keep using. * @@ -5944,6 +5968,135 @@ export function createPluginStore(options: PluginStoreOptions) { return { verifiedAt, wrote: true }; }, + /** + * Start an Arcade authorization attempt for one person: take the key if it is free, expired or + * completed, and refuse if a LIVE attempt holds it. ONE STATEMENT. + * + * `actorId` IS THE SESSION SUBJECT (`users.id`), NEVER `credentialActorId`. This file reads + * `input.credentialActorId ?? input.actorId`, and on a Team Bot those differ, and neither is the + * person holding the browser. The verifier finds the attempt from the returning browser's + * session, so any other key writes attempts a browser can never match. + * + * ONE STATEMENT BECAUSE A READ-THEN-WRITE LETS TWO REQUESTS BOTH SEE "NO LIVE ATTEMPT" AND BOTH + * INSERT. The conditional `ON CONFLICT ... DO UPDATE ... WHERE` is what makes the claim atomic: + * the second request waits on the first's row and then evaluates the WHERE against it. The + * live row is answered by the same statement, in a branch that only produces rows when the + * claim produced none. It can be null: a rival that committed after this statement took its + * snapshot is live but not yet visible to it, and the caller's 409 then names nothing. + * + * `created_at` is truncated to milliseconds so the `Date` handed back names the stored value + * exactly. {@link recordAuthorizationMint} and {@link discardAuthorizationAttempt} match on it, + * and a microsecond the `Date` cannot carry would match nothing. + */ + async claimAuthorizationAttempt(input: { + actorId: string; + serverId: string; + action: string; + account: AccountRef; + }): Promise< + | { claimed: true; attempt: PendingAuthorization } + | { claimed: false; live: PendingAuthorization | null } + > { + const rows = await database.execute(sql` + WITH claimed AS ( + INSERT INTO pending_authorizations + (actor_id, server_id, action, holder, user_id, vendor_user_id, created_at) + VALUES ( + ${input.actorId}, ${input.serverId}, ${input.action}, ${input.account.holder}, + ${input.account.holder === "person" ? input.account.userId : null}, + ${input.account.vendorUserId}, date_trunc('milliseconds', now()) + ) + ON CONFLICT (actor_id) DO UPDATE SET + server_id = EXCLUDED.server_id, + action = EXCLUDED.action, + holder = EXCLUDED.holder, + user_id = EXCLUDED.user_id, + vendor_user_id = EXCLUDED.vendor_user_id, + auth_id = NULL, + created_at = EXCLUDED.created_at, + completed_at = NULL, + outcome = NULL + WHERE pending_authorizations.created_at < now() - interval '15 minutes' + OR pending_authorizations.completed_at IS NOT NULL + RETURNING * + ) + SELECT true AS claimed, claimed.* FROM claimed + UNION ALL + SELECT false AS claimed, p.* FROM pending_authorizations p + WHERE p.actor_id = ${input.actorId} + AND NOT EXISTS (SELECT 1 FROM claimed) + `); + const row = rows[0] as Record | undefined; + if (!row) return { claimed: false, live: null }; + const attempt = pendingAuthorizationFromRow(row); + return row.claimed === true + ? { claimed: true, attempt } + : { claimed: false, live: attempt }; + }, + + /** + * Write the `ar_...` the vendor minted onto the attempt, matched on `(actor_id, created_at)`. + * Answers whether it wrote: false is an attempt that was discarded or replaced meanwhile. + */ + async recordAuthorizationMint(input: { + actorId: string; + createdAt: Date; + authId: string; + }): Promise { + const written = await database + .update(pendingAuthorizations) + .set({ authId: input.authId }) + .where( + and( + eq(pendingAuthorizations.actorId, input.actorId), + eq(pendingAuthorizations.createdAt, input.createdAt), + ), + ) + .returning({ actorId: pendingAuthorizations.actorId }); + return written.length > 0; + }, + + /** + * Delete one attempt, matched on `(actor_id, created_at)` SO IT CAN NEVER DELETE A NEWER + * ATTEMPT BY THE SAME ACTOR. Matching on `auth_id` would be wrong: an attempt whose mint never + * completed has none, and that is exactly the one most worth cancelling. + */ + async discardAuthorizationAttempt(input: { + actorId: string; + createdAt: Date; + }): Promise { + const deleted = await database + .delete(pendingAuthorizations) + .where( + and( + eq(pendingAuthorizations.actorId, input.actorId), + eq(pendingAuthorizations.createdAt, input.createdAt), + ), + ) + .returning({ actorId: pendingAuthorizations.actorId }); + return deleted.length > 0; + }, + + /** + * The live attempt for a session subject (`users.id`, never `credentialActorId`), or null when + * there is none, it is past the 15-minute TTL, or it has completed. + */ + async authorizationAttemptFor( + actorId: string, + ): Promise { + const [row] = await database + .select() + .from(pendingAuthorizations) + .where( + and( + eq(pendingAuthorizations.actorId, actorId), + isNull(pendingAuthorizations.completedAt), + sql`${pendingAuthorizations.createdAt} >= now() - interval '15 minutes'`, + ), + ); + return row ?? null; + }, + /** * Spend one call on this person's key, and say what the vendor made of it. * diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts new file mode 100644 index 000000000..658615955 --- /dev/null +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -0,0 +1,241 @@ +// server/tests/arcade-attempt-store.integration.test.ts +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { mcpServers, pendingAuthorizations } from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import type { AccountRef } from "../src/plugins/shared-accounts"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const serverId = `arcade-attempt-store-${suite}`; + +const broker: ConnectedAppBroker = { + // Arcade holds the grant at the vendor, not locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: false, + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme-bot", +}; + +const policy = { mode: "enforce" as const, deny: [], allow: ["true"] }; + +const store = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => policy, + broker, + deploymentId: `acme-${suite}`, + sharedUse: async () => ({ allowed: true }), +}); + +const account = (actor: string): AccountRef => ({ + holder: "person", + userId: actor, + vendorUserId: `v-${actor}`, +}); + +const claim = (actorId: string, action = "connect") => + store.claimAuthorizationAttempt({ + actorId, + serverId, + action, + account: account(actorId), + }); + +const age = (actorId: string, interval: string) => + database.execute( + sql`update pending_authorizations set created_at = created_at - ${interval}::interval where actor_id = ${actorId}`, + ); + +const actors: string[] = []; +const actor = (name: string) => { + const id = `${name}-${suite}`; + actors.push(id); + return id; +}; + +beforeAll(async () => { + await database.insert(mcpServers).values({ + id: serverId, + title: "Attempt store", + vendor: "Arcade", + url: "https://example.invalid/mcp", + }); +}); + +afterAll(async () => { + // Cascades to the attempts. + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); +}); + +describe("claimAuthorizationAttempt", () => { + test("succeeds when no attempt exists", async () => { + const a = actor("fresh"); + const got = await claim(a); + expect(got.claimed).toBe(true); + if (!got.claimed) return; + expect(got.attempt.actorId).toBe(a); + expect(got.attempt.serverId).toBe(serverId); + expect(got.attempt.holder).toBe("person"); + expect(got.attempt.userId).toBe(a); + expect(got.attempt.vendorUserId).toBe(`v-${a}`); + expect(got.attempt.authId).toBeNull(); + }); + + test("a second claim while the first is live returns zero rows and answers the live row", async () => { + const a = actor("live"); + const first = await claim(a, "connect"); + const second = await claim(a, "reconnect"); + expect(first.claimed).toBe(true); + expect(second.claimed).toBe(false); + if (first.claimed && !second.claimed) { + expect(second.live?.actorId).toBe(a); + expect(second.live?.action).toBe("connect"); + expect(second.live?.createdAt.getTime()).toBe( + first.attempt.createdAt.getTime(), + ); + } + }); + + test("succeeds after the first expired, replacing it", async () => { + const a = actor("expired"); + await claim(a, "old"); + await age(a, "16 minutes"); + const got = await claim(a, "new"); + expect(got.claimed).toBe(true); + const rows = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, a)); + expect(rows.length).toBe(1); + expect(rows[0]?.action).toBe("new"); + }); + + test("succeeds after the first completed, replacing it", async () => { + const a = actor("completed"); + await claim(a, "old"); + await database.execute( + sql`update pending_authorizations set completed_at = now(), outcome = 'completed' where actor_id = ${a}`, + ); + const got = await claim(a, "new"); + expect(got.claimed).toBe(true); + const rows = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, a)); + expect(rows[0]?.action).toBe("new"); + expect(rows[0]?.completedAt).toBeNull(); + expect(rows[0]?.outcome).toBeNull(); + }); + + test("two concurrent claims by one actor: exactly one wins", async () => { + const a = actor("race"); + const results = await Promise.all([claim(a, "one"), claim(a, "two")]); + expect(results.filter((r) => r.claimed).length).toBe(1); + expect(results.filter((r) => !r.claimed).length).toBe(1); + }); +}); + +describe("recordAuthorizationMint", () => { + test("writes the ar_ onto the attempt and leaves another actor's alone", async () => { + const a = actor("mint"); + const other = actor("mint-other"); + const mine = await claim(a); + await claim(other); + if (!mine.claimed) throw new Error("claim failed"); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt: mine.attempt.createdAt, + authId: "ar_abc", + }); + expect(wrote).toBe(true); + const got = await store.authorizationAttemptFor(a); + expect(got?.authId).toBe("ar_abc"); + expect((await store.authorizationAttemptFor(other))?.authId).toBeNull(); + }); + + test("a mint against a different created_at writes nothing", async () => { + const a = actor("mint-stale"); + await claim(a); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt: new Date(0), + authId: "ar_stale", + }); + expect(wrote).toBe(false); + expect((await store.authorizationAttemptFor(a))?.authId).toBeNull(); + }); +}); + +describe("discardAuthorizationAttempt", () => { + test("deletes the named attempt, an unminted one included", async () => { + const a = actor("discard"); + const got = await claim(a); + if (!got.claimed) throw new Error("claim failed"); + const deleted = await store.discardAuthorizationAttempt({ + actorId: a, + createdAt: got.attempt.createdAt, + }); + expect(deleted).toBe(true); + expect(await store.authorizationAttemptFor(a)).toBeNull(); + }); + + test("leaves a NEWER attempt by the same actor standing", async () => { + const a = actor("newer"); + const older = await claim(a, "older"); + if (!older.claimed) throw new Error("claim failed"); + await age(a, "16 minutes"); + const newer = await claim(a, "newer"); + expect(newer.claimed).toBe(true); + if (!newer.claimed) return; + expect(newer.attempt.createdAt.getTime()).toBeGreaterThan( + older.attempt.createdAt.getTime(), + ); + + // Aimed at the OLD attempt's created_at: it must not reach the new one. + const deleted = await store.discardAuthorizationAttempt({ + actorId: a, + createdAt: older.attempt.createdAt, + }); + expect(deleted).toBe(false); + const standing = await store.authorizationAttemptFor(a); + expect(standing?.action).toBe("newer"); + expect(standing?.createdAt.getTime()).toBe( + newer.attempt.createdAt.getTime(), + ); + }); +}); + +describe("authorizationAttemptFor", () => { + test("answers the live attempt", async () => { + const a = actor("for-live"); + await claim(a); + expect((await store.authorizationAttemptFor(a))?.actorId).toBe(a); + }); + + test("answers null when there is none", async () => { + expect(await store.authorizationAttemptFor(actor("for-none"))).toBeNull(); + }); + + test("answers null past the TTL", async () => { + const a = actor("for-expired"); + await claim(a); + await age(a, "16 minutes"); + expect(await store.authorizationAttemptFor(a)).toBeNull(); + }); +}); From 7b0f85ba149557d5607183510d65e92023f70e2a Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:40:19 +0200 Subject: [PATCH 039/232] feat(arcade): the adapter's auth-config and field-entry members ensureAuthConfig answers not-needed and deleteAuthConfig is a no-op, since Arcade's auth provider is registered once in the dashboard. connectionFields and connectWithFields refuse, because admin.secrets is deployment-wide. accountName reads provider_user_info.name then login. Stub comments now name the real micro-tasks. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 56 +++++++++++++++------ server/tests/arcade-adapter.test.ts | 73 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 59dc5c19e..9618b804c 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1,6 +1,10 @@ import Arcade, { APIError } from "@arcadeai/arcadejs"; import type { ArcadeActions, ArcadeFailure } from "./arcade"; -import type { BrokerApp, ConnectedAppBroker } from "./broker"; +import { + BrokerRefusalError, + type BrokerApp, + type ConnectedAppBroker, +} from "./broker"; /** * THE VENDOR'S ONLY DOOR INTO THE SERVER. This is the one file under `server/src` that imports @@ -119,6 +123,9 @@ async function listApps(client: Arcade): Promise { return [...counts.values()]; } +const FIELDS_REFUSAL = + "Arcade has no per-person field entry: what it asks for is a deployment-wide secret (admin.secrets, e.g. GITHUB_SERVER_URL) that an operator sets once for everybody, so a form asking a person to type one would collect a value that is not theirs and share it with everyone. Set it in the Arcade dashboard instead."; + const notYet = (task: string, member: string) => () => Promise.reject(new Error(`${member} is not implemented yet (${task}).`)); @@ -160,20 +167,39 @@ export function createArcadeClient(apiKey: string): { const broker: ConnectedAppBroker = { grantIsLocal: false, listApps: () => listApps(client), - // The members below are filled by later micro-tasks; they throw until then. - // 5.3: ensureAuthConfig, deleteAuthConfig, authorize - ensureAuthConfig: notYet("micro-task 5.3", "ensureAuthConfig"), - deleteAuthConfig: notYet("micro-task 5.3", "deleteAuthConfig"), - authorize: notYet("micro-task 5.3", "authorize"), - // 6.1: isConnected, revoke, revokeAccount - isConnected: notYet("micro-task 6.1", "isConnected"), - revoke: notYet("micro-task 6.1", "revoke"), - revokeAccount: notYet("micro-task 6.1", "revokeAccount"), - // 6.2: connectionFields, connectWithFields - connectionFields: notYet("micro-task 6.2", "connectionFields"), - connectWithFields: notYet("micro-task 6.2", "connectWithFields"), - // 6.3: accountName - accountName: notYet("micro-task 6.3", "accountName"), + // 5.3: no auth config per app. Arcade's equivalent is an auth provider registered once in the + // dashboard for the whole project, so there is nothing to create or delete here. + ensureAuthConfig: async () => "not-needed", + deleteAuthConfig: async () => {}, + // The field-entry pair REFUSES. Composio's field schemes are a person typing their own API key; + // `admin.secrets` is a deployment-wide secret an operator sets once for everybody. + connectionFields: () => + Promise.reject(new BrokerRefusalError(FIELDS_REFUSAL)), + connectWithFields: () => + Promise.reject(new BrokerRefusalError(FIELDS_REFUSAL)), + accountName: async ({ account }) => { + for await (const row of client.admin.userConnections.list({ + user_id: account.vendorUserId, + })) { + const info = row.provider_user_info as + { name?: unknown; login?: unknown } | null | undefined; + const name = textOf(info?.name) ?? textOf(info?.login); + if (name) return name; + } + return null; + }, + // 6.1: authorize, and confirm_user (the mint pair; confirm_user is not on this seam yet) + authorize: notYet("micro-task 6.1", "authorize"), + // 6.2: isConnected, revoke, revokeAccount (the connection-lifecycle trio). + // `admin.userConnections.delete` takes `connection_id` (`ac_...`), NOT the `id` (`at_...`) the + // listing prints first: measured, `at_...` answers 500 and `ac_...` answers 204. + isConnected: notYet("micro-task 6.2", "isConnected"), + revoke: notYet("micro-task 6.2", "revoke"), + revokeAccount: notYet("micro-task 6.2", "revokeAccount"), + // 6.3: grantsFor and authorizationStatus (the read-only status pair; neither is on this seam + // yet, so there is no stub to throw). `authorizationStatus` must narrow to + // `{ status, userId, scopes }` and nothing else: the raw `auth.status` response carries + // `context.token`, a live provider access token, plus the person's full profile. }; return { actions, broker }; } diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index 46c6eac2c..439a25231 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import Arcade, { APIError } from "@arcadeai/arcadejs"; import { callTool, useArcadeClient } from "../src/plugins/arcade"; +import { BrokerRefusalError } from "../src/plugins/broker"; import { createArcadeClient } from "../src/plugins/arcade-adapter"; /** @@ -125,4 +126,76 @@ describe("createArcadeClient", () => { actions.execute({ tool_name: "X.Y", input: {}, user_id: "u" }), ).rejects.toBe(boom); }); + + const connectionsProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).admin.userConnections, + ) as Methods; + const account = { vendorUserId: "u1" } as never; + + test("ensureAuthConfig answers not-needed in every case", async () => { + const { broker } = createArcadeClient("never-dialled"); + for (const toolkit of ["Github", "Gmail", "GithubApi"]) + expect( + await broker.ensureAuthConfig({ + toolkit, + connection: { kind: "consent" }, + } as never), + ).toBe("not-needed"); + }); + + test("deleteAuthConfig is a no-op", async () => { + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.deleteAuthConfig("Github")).toBeUndefined(); + }); + + test("connectionFields and connectWithFields refuse, naming the deployment-wide secret", async () => { + const { broker } = createArcadeClient("never-dialled"); + const a = broker.connectionFields({ + toolkit: "Github", + authScheme: "API_KEY", + } as never); + await expect(a).rejects.toBeInstanceOf(BrokerRefusalError); + await expect(a).rejects.toThrow(/deployment-wide secret/); + const b = broker.connectWithFields({ + account, + toolkit: "Github", + authScheme: "API_KEY", + values: { k: "v" }, + } as never); + await expect(b).rejects.toBeInstanceOf(BrokerRefusalError); + await expect(b).rejects.toThrow(/deployment-wide secret/); + }); + + test("accountName reads provider_user_info.name, then .login, else null", async () => { + const rows: unknown[] = []; + stub(connectionsProto(), "list", () => + (async function* () { + for (const r of rows) yield r; + })(), + ); + const { broker } = createArcadeClient("never-dialled"); + const ask = () => broker.accountName({ account, toolkit: "Github" }); + rows.push({ provider_user_info: { name: "Ada", login: "ada1" } }); + expect(await ask()).toBe("Ada"); + rows.length = 0; + rows.push({ provider_user_info: { login: "ada1" } }); + expect(await ask()).toBe("ada1"); + rows.length = 0; + rows.push({ provider_user_info: {} }); + expect(await ask()).toBeNull(); + }); + + test("no app resolves to a fields connection", async () => { + stub(toolsProto(), "list", () => + Promise.resolve({ + items: [toolOf("Github", 0), toolOf("Gmail", 0)], + total_count: 2, + }), + ); + const apps = await createArcadeClient("never-dialled").broker.listApps(); + expect(apps.length).toBeGreaterThan(0); + for (const app of apps) + expect(["consent", "unsupported"]).toContain(app.connection.kind); + }); }); From 14241c98566ac0b0f82a07104c9b4a75faa09305 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 11:42:26 +0200 Subject: [PATCH 040/232] style(arcade): format the adapter's auth-config members Line wrapping only, produced by `bun run format` after 7b0f85b. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 9618b804c..4e077d5f7 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -182,7 +182,9 @@ export function createArcadeClient(apiKey: string): { user_id: account.vendorUserId, })) { const info = row.provider_user_info as - { name?: unknown; login?: unknown } | null | undefined; + | { name?: unknown; login?: unknown } + | null + | undefined; const name = textOf(info?.name) ?? textOf(info?.login); if (name) return name; } From 2c5f8d23ade83c8e61da036bc1f46a692be2e9e8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 12:52:21 +0200 Subject: [PATCH 041/232] test: guard that every ts-expect-error test file is in tsconfig include An unincluded directive is no assertion at all: tsc is the only thing that checks it, and a test file not named in server/tsconfig.json's include is type-checked by nothing. tests/db-client-address.test.ts:118 was exactly that. The new guard reads include, walks server/tests, and fails naming file:line for any directive-carrying file include does not cover. db-client-address.test.ts is added to include so it lands green. Including it surfaced no type errors; bun run typecheck is clean. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/ts-expect-error-coverage.test.ts | 97 +++++++++++++++++++ server/tsconfig.json | 3 +- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 server/tests/ts-expect-error-coverage.test.ts diff --git a/server/tests/ts-expect-error-coverage.test.ts b/server/tests/ts-expect-error-coverage.test.ts new file mode 100644 index 000000000..935a347df --- /dev/null +++ b/server/tests/ts-expect-error-coverage.test.ts @@ -0,0 +1,97 @@ +// server/tests/ts-expect-error-coverage.test.ts +/** + * Guard: every test file under `server/tests` that carries a ts-expect-error directive must be named + * in `server/tsconfig.json`'s `include`. + * + * AN UNINCLUDED DIRECTIVE IS NOT A WEAKER ASSERTION, IT IS NO ASSERTION. The directive says "the next + * line must fail to compile", and the only thing that ever checks that is `tsc`. `include` names + * `src`, `scripts` and a handful of test files, and the rest of `tests` is type-checked by nothing, + * so a directive in a file not named there sits in the source asserting nothing while reading to the + * next maintainer as coverage. The wrong-way-round call it guards can compile cleanly for good and no + * one hears about it. + * + * So the convention is: a test file whose subject is a TYPE is named in `include`, one file and not + * the directory (see the comment in the tsconfig). This guard is what makes that enforceable rather + * than remembered. It fails naming each file that is not covered, as `file:line`. + * + * Like `brokered-provenance.test.ts` it reads text and imports nothing from the code it guards. The + * tsconfig is JSONC (its comments are the point), so the comments are stripped with a scan that + * respects strings before it is parsed. + */ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +const SERVER_DIR = resolve(import.meta.dir, ".."); +const TESTS_DIR = join(SERVER_DIR, "tests"); + +/** The directive, in either comment form. A bare mention in prose does not match. */ +const DIRECTIVE = /(\/\/|\/\*)\s*@ts-expect-error\b/; + +/** JSONC to JSON: drop `//` and block comments, leaving string contents (and their slashes) alone. */ +function stripJsonComments(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (c === '"') { + let j = i + 1; + while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1; + out += text.slice(i, j + 1); + i = j + 1; + } else if (c === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + } else if (c === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + i = end === -1 ? text.length : end + 2; + } else { + out += c; + i++; + } + } + return out; +} + +function readInclude(): string[] { + const raw = readFileSync(join(SERVER_DIR, "tsconfig.json"), "utf8"); + const include = JSON.parse(stripJsonComments(raw)).include; + if (!Array.isArray(include)) throw new Error("tsconfig.json has no include"); + return include.map((p: string) => resolve(SERVER_DIR, p)); +} + +/** Covered when named exactly, or when it sits under an included directory. */ +function isCovered(file: string, include: string[]): boolean { + return include.some((p) => file === p || file.startsWith(`${p}/`)); +} + +function walk(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = join(dir, e.name); + if (e.isDirectory()) return walk(p); + return e.name.endsWith(".ts") || e.name.endsWith(".tsx") ? [p] : []; + }); +} + +test("every test file carrying a ts-expect-error directive is named in tsconfig include", () => { + const include = readInclude(); + const offenders: string[] = []; + for (const file of walk(TESTS_DIR).sort()) { + if (isCovered(file, include)) continue; + readFileSync(file, "utf8") + .split("\n") + .forEach((line, i) => { + if (DIRECTIVE.test(line)) { + offenders.push(`${relative(SERVER_DIR, file)}:${i + 1}`); + } + }); + } + expect(offenders).toEqual([]); +}); + +test("the directive pattern takes both comment forms and leaves prose", () => { + // The fixtures are assembled so this file does not itself contain the directive it scans for. + const d = "@ts-expect" + "-error"; + expect(DIRECTIVE.test(` // ${d} the call`)).toBe(true); + expect(DIRECTIVE.test(`x(); /* ${d} */`)).toBe(true); + expect(DIRECTIVE.test(" * the ts-expect-error directive is checked by tsc")).toBe(false); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index fffa6aeb2..11571fb56 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -41,6 +41,7 @@ "scripts", "../scripts/composio-smoke.ts", "tests/store-provider-required.test.ts", - "tests/arcade-transport-registry.test.ts" + "tests/arcade-transport-registry.test.ts", + "tests/db-client-address.test.ts" ] } From d09b533da6b8025c2f36786dcab8d2ed15481d8b Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 12:59:40 +0200 Subject: [PATCH 042/232] feat(arcade): the completion transaction, one conditional UPDATE and three writes or none The attempt row is the serialization point and the database enforces it: an `UPDATE ... WHERE actor_id = $1 AND auth_id = $2 AND completed_at IS NULL RETURNING` is the first statement, and zero rows stops the method having written nothing. Only the winner's `brokered_connections` upsert and `mcp.account_connected` insert run, both on the same transaction, so either all three land or none do. The audit row carries the action and the granted scopes and nothing else from the vendor's reply. The loser answers exactly as the winner does, because neither "somebody else completed this" nor "this is not the attempt the callback names" is a thing the caller can act on differently: both wrote nothing, and a replayed callback is a no-op rather than an error. Also closes an attempt as `failed` or `unreconciled`, matched on (actor_id, auth_id), which is all a returning callback holds. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 209 +++++++++++ ...completion-transaction.integration.test.ts | 354 ++++++++++++++++++ 2 files changed, 563 insertions(+) create mode 100644 server/tests/arcade-completion-transaction.integration.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 30c9caac6..254f4f548 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3,6 +3,7 @@ import { type ApprovalGate, currentApprovalContext } from "../approvals/types"; import { type AuditInitiator, type AuditStore, + createAuditStore, recordAuditEvent, } from "../audit"; import { @@ -1584,6 +1585,31 @@ export function createPluginStore(options: PluginStoreOptions) { eq(brokeredConnections.holder, "deployment"), ); + /** + * The one statement behind `failAuthorizationAttempt` and `markAuthorizationUnreconciled`, so the + * two verdicts cannot drift into two row shapes. `completed_at` is truncated to milliseconds for + * the reason `claimAuthorizationAttempt` truncates `created_at`. + */ + const closeAuthorizationAttempt = async ( + input: { actorId: string; authId: string }, + outcome: "failed" | "unreconciled", + ) => { + const written = await database + .update(pendingAuthorizations) + .set({ + completedAt: sql`date_trunc('milliseconds', now())`, + outcome, + }) + .where( + and( + eq(pendingAuthorizations.actorId, input.actorId), + eq(pendingAuthorizations.authId, input.authId), + ), + ) + .returning({ actorId: pendingAuthorizations.actorId }); + return written.length > 0; + }; + /** * The token one call goes out with, and whose it is — decided from `access.credential`, so that * this function and the audit row cannot disagree about whose account a call ran in. @@ -6077,6 +6103,189 @@ export function createPluginStore(options: PluginStoreOptions) { return deleted.length > 0; }, + /** + * Finish one Arcade authorization: consume the attempt, record the connection and file the + * trail, ALL THREE IN ONE TRANSACTION OR NONE OF THEM. + * + * THE ATTEMPT ROW IS THE SERIALIZATION POINT AND THE DATABASE ENFORCES IT. The first statement + * is a conditional `UPDATE ... WHERE actor_id = $1 AND auth_id = $2 AND completed_at IS NULL + * RETURNING`, and it is what makes completion happen exactly once. A read of the attempt + * followed by a write would not: two callbacks can both read the row before either consumes it, + * and both would then write a connection and a second `mcp.account_connected`. The second + * caller's UPDATE instead waits on the first's row lock, re-evaluates `completed_at IS NULL` + * against the committed row, and matches nothing. + * + * AN `auth_id` IN AN AUDIT PAYLOAD ENFORCES NOTHING, which is the shape this replaces. + * {@link auditEvents.id} is `uuid().defaultRandom()`, so the trail has no key a second callback + * could collide with — it would simply hold two rows for one connection, and the connection + * upsert would have run twice on the way there. + * + * ZERO ROWS MEANS STOP, AND THE CALLER IS TOLD NOTHING, which is deliberate. Zero rows is + * either somebody else completing this attempt first or a callback naming an attempt that is + * not this one, and NEITHER is a thing this deployment can tell apart or should act on + * differently: both wrote nothing and both leave the browser looking at an account that is + * connected. So this answers the same for the loser as for the winner, and a replayed callback + * is a no-op rather than an error somebody has to explain. A caller wanting to know what the + * row says reads {@link authorizationAttemptFor}. + * + * THE CONNECTION AND THE AUDIT ROW ARE IN THE SAME TRANSACTION AS THAT UPDATE and not merely + * after it. A failure between them used to leave the attempt consumed and nothing recording + * what it was consumed FOR: the person's account is live at the vendor, this deployment holds + * no `brokered_connections` row — which is the whole of the permission a brokered call is + * decided on — and the attempt can never be completed again. The audit insert is bound to the + * transaction for the same reason, through {@link createAuditStore} over the transaction handle + * rather than the store's own `auditStore`, which holds the pool and would commit on its own. + * + * `scopes` IS WHAT THE VENDOR GRANTED, AND IT IS THE ONLY THING FROM THAT ANSWER THAT IS + * WRITTEN ANYWHERE. Arcade's `auth.status` carries a live provider access token and an + * authorization URL beside it; neither reaches this row, this method's arguments, a log line or + * an error. What the trail gets is the action the attempt was for and the scopes that came + * back, which is exactly what is needed later to explain a tool call refused for want of a + * scope — the question {@link mcpUserCredentials.scope} exists to answer for a `user-oauth` + * grant. `provider` is the authorization provider this deployment ASKED for, carried down from + * the attempt rather than read back out of the vendor's reply. + */ + async completeAuthorizationAttempt(input: { + actorId: string; + authId: string; + toolkit: string; + account: AccountRef; + action: string; + scopes: string[]; + provider: string; + }): Promise { + await database.transaction(async (transaction) => { + /* + * ONE STATEMENT, AND THE FIRST ONE. `completed_at` is truncated to milliseconds for the + * reason `claimAuthorizationAttempt` truncates `created_at`: a `Date` carries only + * milliseconds, and a column this deployment writes at microsecond precision is one no + * later `Date`-valued filter can ever match. Nothing matches on this column today; writing + * it the same way is what keeps that true if something does. + */ + const claimed = await transaction.execute(sql` + UPDATE pending_authorizations + SET completed_at = date_trunc('milliseconds', now()), + outcome = 'completed' + WHERE actor_id = ${input.actorId} + AND auth_id = ${input.authId} + AND completed_at IS NULL + RETURNING actor_id + `); + if (claimed.length === 0) return; + + // Read inside the transaction, under the attempt's lock, so the answer the audit row + // carries is the one this very write acted on rather than one a rival moved underneath it. + const [existing] = await transaction + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where(accountRow(input.toolkit, input.account)); + + const verifiedAt = new Date(); + const set = { + verified: true, + verifiedAt, + // NOTHING WAS SPENT TO EARN THAT FLAG. A consent screen is the vendor's own yes, and no + // action of the app's is ever called against it; see `confirmBrokeredConnection`, which + // writes the same pair for the same reason. + probeAction: null, + updatedAt: verifiedAt, + }; + await transaction + .insert(brokeredConnections) + .values({ + // The broker this row was actually minted at, which is this store's own — never a + // literal, for the reason `recordBrokeredConnection` sets out at length. + provider: brokerProvider, + app: input.toolkit, + holder: input.account.holder, + userId: + input.account.holder === "person" ? input.account.userId : null, + vendorUserId: input.account.vendorUserId, + ...set, + }) + .onConflictDoUpdate( + input.account.holder === "person" + ? { + target: [ + brokeredConnections.provider, + brokeredConnections.app, + brokeredConnections.userId, + ], + targetWhere: sql`${brokeredConnections.holder} = 'person'`, + set, + } + : { + target: [ + brokeredConnections.provider, + brokeredConnections.app, + ], + targetWhere: sql`${brokeredConnections.holder} = 'deployment'`, + set, + }, + ); + + await recordAuditEvent( + createAuditStore(transaction as unknown as Database), + { + eventType: "mcp.account_connected", + targetType: "mcp_server", + // The app, the id every other writer of this event files a brokered connection under, + // so one query answers what happened to one person's access to one app however it + // began and however it ended. + targetId: input.toolkit, + payload: { + actor: input.actorId, + server: input.toolkit, + /* + * WHAT THE VENDOR GRANTED, WHICH A BROKERED ROW HAS NEVER BEEN ABLE TO SAY BEFORE. + * Both Composio writers carry `""` because Composio holds the grant and never tells + * this deployment what it covers; Arcade does tell us, on the key all four writers + * already agree on, so the same query reads both. + */ + scope: input.scopes.join(" "), + // The action the attempt was for: what this grant was obtained in order to call. + action: input.action, + provider: input.provider, + reconnected: Boolean(existing), + holder: input.account.holder, + }, + }, + ); + }); + }, + + /** + * Close an attempt as `failed` or `unreconciled`, matched on `(actor_id, auth_id)`. + * + * ON THE PAIR AND NOT ON `created_at`, unlike {@link recordAuthorizationMint} and + * {@link discardAuthorizationAttempt}, because the caller here is the returning callback and + * all it holds is the `ar_...`. An attempt with no `auth_id` matches nothing, which is correct: + * no callback can be about an attempt that never minted one. + * + * NOT CONDITIONAL ON `completed_at IS NULL`, unlike the completion above, and the asymmetry is + * the point. Completion is the write that must happen exactly once because it hands out access; + * these two record that it did not, and a second one re-stamping the same verdict grants + * nothing to anybody. + */ + async failAuthorizationAttempt(input: { + actorId: string; + authId: string; + }): Promise { + return await closeAuthorizationAttempt(input, "failed"); + }, + + /** + * Mark an attempt whose outcome this deployment could not settle — the vendor answered, and + * what it answered could not be reconciled with the attempt. Kept apart from `failed` so an + * operator can tell "the person declined" from "we do not know what happened". + */ + async markAuthorizationUnreconciled(input: { + actorId: string; + authId: string; + }): Promise { + return await closeAuthorizationAttempt(input, "unreconciled"); + }, + /** * The live attempt for a session subject (`users.id`, never `credentialActorId`), or null when * there is none, it is past the 15-minute TTL, or it has completed. diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts new file mode 100644 index 000000000..609c899b7 --- /dev/null +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -0,0 +1,354 @@ +// server/tests/arcade-completion-transaction.integration.test.ts +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, like } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { + auditEvents, + brokeredConnections, + mcpServers, + pendingAuthorizations, +} from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import type { AccountRef } from "../src/plugins/shared-accounts"; +import { createPluginStore } from "../src/plugins/store"; +import { testDatabaseUrl } from "./support/database"; + +/* + * FOUR CONNECTIONS RATHER THAN THE SHARED `TEST_POOL` OF TWO, because the race below drives two + * completion TRANSACTIONS at once and the second one blocks on the first's row lock while holding + * its own connection. Two is the exact floor for that pair and leaves nothing for the reads that + * follow if the driver has not handed a connection back yet; `support/database.ts` says a file + * wanting a different pool asks for one itself. + */ +const database = createDatabase(testDatabaseUrl(), { max: 4 }); +const suite = randomUUID().slice(0, 8); +const serverId = `arcade-completion-${suite}`; + +const broker: ConnectedAppBroker = { + // Arcade holds the grant at the vendor, not locally; see `ConnectedAppBroker.grantIsLocal`. + grantIsLocal: false, + // Arcade runs the correlated, pollable flow, so this stub DECLARES the sub-surface rather + // than a null; nothing here reaches either member. See `ConnectedAppBroker.authorizations`. + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme-bot", +}; + +const policy = { mode: "enforce" as const, deny: [], allow: ["true"] }; + +const storeOn = (handle: typeof database) => + createPluginStore({ + brokerProvider: "arcade", + database: handle, + auditStore: createAuditStore(handle), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => policy, + broker, + deploymentId: `acme-${suite}`, + sharedUse: async () => ({ allowed: true }), + }); + +const store = storeOn(database); + +/** + * The same store with the AUDIT INSERT made to throw, and nothing else changed. + * + * The failure is injected at the DATABASE HANDLE rather than at the store's `auditStore` option, + * because the audit write that has to be atomic with the connection write is the one bound to the + * transaction — a throwing `auditStore` passed into `createPluginStore` would never be the object + * that write goes through, and the test would prove nothing. This proxy lets the connection upsert + * through and refuses the `audit_events` insert, so it fires BETWEEN the two writes, which is where + * the rollback has to hold. + */ +const brokenAuditDatabase = new Proxy(database, { + get(target, property, receiver) { + if (property !== "transaction") { + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + } + return (work: (tx: unknown) => Promise) => + target.transaction((transaction) => + work( + new Proxy(transaction, { + get(txTarget, txProperty, txReceiver) { + if (txProperty === "insert") { + return (table: unknown) => { + if (table === auditEvents) + throw new Error("injected audit failure"); + return txTarget.insert(table as never); + }; + } + const value = Reflect.get(txTarget, txProperty, txReceiver); + return typeof value === "function" ? value.bind(txTarget) : value; + }, + }), + ), + ); + }, +}) as typeof database; + +const brokenAuditStore = storeOn(brokenAuditDatabase); + +const account = (actor: string): AccountRef => ({ + holder: "person", + userId: actor, + vendorUserId: `v-${actor}`, +}); + +const actor = (name: string) => `${name}-${suite}`; +const toolkit = (name: string) => `tk-${name}-${suite}`; + +/** A claimed attempt carrying a minted `ar_...`, which is what a callback arrives against. */ +const attemptWith = async (actorId: string, authId: string) => { + const claimed = await store.claimAuthorizationAttempt({ + actorId, + serverId, + action: "Google.SendEmail", + account: account(actorId), + }); + if (!claimed.claimed) throw new Error("claim failed"); + const minted = await store.recordAuthorizationMint({ + actorId, + createdAt: claimed.attempt.createdAt, + authId, + }); + if (!minted) throw new Error("mint failed"); + return claimed.attempt; +}; + +const connectionsFor = (app: string) => + database + .select() + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, "arcade"), + eq(brokeredConnections.app, app), + ), + ); + +const connectedEventsFor = (app: string) => + database + .select() + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_connected"), + eq(auditEvents.targetId, app), + ), + ); + +const attemptRow = async (actorId: string) => { + const [row] = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, actorId)); + if (!row) throw new Error("attempt row gone"); + return row; +}; + +beforeAll(async () => { + await database.insert(mcpServers).values({ + id: serverId, + title: "Completion transaction", + vendor: "Arcade", + url: "https://example.invalid/mcp", + }); +}); + +afterAll(async () => { + // Cascades to the attempts. The connections are keyed on the app, not the server, so they go + // separately; the audit rows cannot go at all, which is what `audit_events_append_only` is for. + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + await database + .delete(brokeredConnections) + .where(like(brokeredConnections.app, `%-${suite}`)); +}); + +describe("completeAuthorizationAttempt", () => { + test("one call writes exactly one connection and one audit event naming the action and scopes", async () => { + const a = actor("single"); + const app = toolkit("single"); + await attemptWith(a, "ar_single"); + + await store.completeAuthorizationAttempt({ + actorId: a, + authId: "ar_single", + toolkit: app, + account: account(a), + action: "Google.SendEmail", + scopes: ["gmail.send", "gmail.readonly"], + provider: "google", + }); + + const connections = await connectionsFor(app); + expect(connections.length).toBe(1); + expect(connections[0]?.holder).toBe("person"); + expect(connections[0]?.userId).toBe(a); + expect(connections[0]?.vendorUserId).toBe(`v-${a}`); + expect(connections[0]?.verified).toBe(true); + expect(connections[0]?.verifiedAt).not.toBeNull(); + expect(connections[0]?.probeAction).toBeNull(); + + const events = await connectedEventsFor(app); + expect(events.length).toBe(1); + const payload = events[0]?.payload as Record; + expect(payload.action).toBe("Google.SendEmail"); + expect(payload.scope).toBe("gmail.send gmail.readonly"); + expect(payload.server).toBe(app); + expect(payload.holder).toBe("person"); + + const attempt = await attemptRow(a); + expect(attempt.outcome).toBe("completed"); + expect(attempt.completedAt).not.toBeNull(); + }); + + test("two CONCURRENT calls write exactly one connection and one audit event, and the loser answers as the winner would", async () => { + const a = actor("race"); + const app = toolkit("race"); + await attemptWith(a, "ar_race"); + + const call = () => + store.completeAuthorizationAttempt({ + actorId: a, + authId: "ar_race", + toolkit: app, + account: account(a), + action: "Google.SendEmail", + scopes: ["gmail.send"], + provider: "google", + }); + + // CONCURRENTLY, on the real pool. A sequential replay passes against an implementation with no + // serialization at all, so it would be no evidence about this method. + const answers = await Promise.all([call(), call()]); + // The loser is indistinguishable from the winner by its answer, which is the point: a replay is + // a no-op and not an error. + expect(answers).toEqual([undefined, undefined]); + + expect((await connectionsFor(app)).length).toBe(1); + expect((await connectedEventsFor(app)).length).toBe(1); + + const attempt = await attemptRow(a); + expect(attempt.outcome).toBe("completed"); + expect(attempt.completedAt).not.toBeNull(); + }); + + test("a failure between the connection write and the audit write persists NEITHER", async () => { + const a = actor("rollback"); + const app = toolkit("rollback"); + await attemptWith(a, "ar_rollback"); + + await expect( + brokenAuditStore.completeAuthorizationAttempt({ + actorId: a, + authId: "ar_rollback", + toolkit: app, + account: account(a), + action: "Google.SendEmail", + scopes: ["gmail.send"], + provider: "google", + }), + ).rejects.toThrow("injected audit failure"); + + // BOTH tables, because "it returned" is not evidence that nothing was written. + expect((await connectionsFor(app)).length).toBe(0); + expect((await connectedEventsFor(app)).length).toBe(0); + + // And the attempt is still claimable, because its UPDATE was in the same transaction. + const attempt = await attemptRow(a); + expect(attempt.completedAt).toBeNull(); + expect(attempt.outcome).toBeNull(); + }); + + test("an auth_id that does not match the row writes nothing and leaves the row UNCHANGED", async () => { + const a = actor("mismatch"); + const app = toolkit("mismatch"); + await attemptWith(a, "ar_real"); + + await store.completeAuthorizationAttempt({ + actorId: a, + authId: "ar_someone_elses", + toolkit: app, + account: account(a), + action: "Google.SendEmail", + scopes: ["gmail.send"], + provider: "google", + }); + + expect((await connectionsFor(app)).length).toBe(0); + expect((await connectedEventsFor(app)).length).toBe(0); + + // THE ROW'S OWN STATE, not a return value. + const attempt = await attemptRow(a); + expect(attempt.completedAt).toBeNull(); + expect(attempt.outcome).toBeNull(); + expect(attempt.authId).toBe("ar_real"); + }); +}); + +describe("failAuthorizationAttempt / markAuthorizationUnreconciled", () => { + test("mark an attempt failed, matched on (actor_id, auth_id)", async () => { + const a = actor("failed"); + await attemptWith(a, "ar_failed"); + + expect( + await store.failAuthorizationAttempt({ + actorId: a, + authId: "ar_other", + }), + ).toBe(false); + let attempt = await attemptRow(a); + expect(attempt.outcome).toBeNull(); + expect(attempt.completedAt).toBeNull(); + + expect( + await store.failAuthorizationAttempt({ actorId: a, authId: "ar_failed" }), + ).toBe(true); + attempt = await attemptRow(a); + expect(attempt.outcome).toBe("failed"); + expect(attempt.completedAt).not.toBeNull(); + }); + + test("mark an attempt unreconciled, matched on (actor_id, auth_id)", async () => { + const a = actor("unreconciled"); + await attemptWith(a, "ar_unreconciled"); + + expect( + await store.markAuthorizationUnreconciled({ + actorId: a, + authId: "ar_other", + }), + ).toBe(false); + expect((await attemptRow(a)).outcome).toBeNull(); + + expect( + await store.markAuthorizationUnreconciled({ + actorId: a, + authId: "ar_unreconciled", + }), + ).toBe(true); + const attempt = await attemptRow(a); + expect(attempt.outcome).toBe("unreconciled"); + expect(attempt.completedAt).not.toBeNull(); + }); +}); From 90d6a5abba61cec23d3a800ccef1cf147355cb84 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:00:52 +0200 Subject: [PATCH 043/232] feat(arcade): grantsFor on the seam and a declared authorizations sub-surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GrantState` lives in `broker.ts`, so nothing imports a type from an adapter. `grantsFor` is the NEUTRAL seam both providers answer — what does this account hold at this app — and Composio implements it rather than throwing. Its auth config fixes the scope set when it is created, so every action in a toolkit shares one state: the answer is one state reported once per action, carrying that config's scopes. Degenerate is not absent, and two implementations of one member answering different questions is the bug this PR already found at `accountName`. `authorizations` is a REQUIRED field whose `null` is a declared statement. A broker literal omitting it fails to type-check, which is what micro-task 4.3's nine failed-open stubs are the evidence for. A route reaches it with `if (!broker.authorizations)` — a presence test on a value the broker declared, never a vendor name. `authorize` does NOT move: the line the nullability falls on is "this broker runs a correlated, pollable authorization flow", not "this broker can start a consent", which both do. No existing Composio behaviour changes; both members are new and nothing calls them yet. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 15 +- server/src/plugins/broker.ts | 106 ++++++++ server/src/plugins/composio-adapter.ts | 92 ++++++- server/tests/account-mode.integration.test.ts | 5 + .../arcade-attempt-store.integration.test.ts | 11 + .../brokered-app-provider.integration.test.ts | 4 + server/tests/composio-adapter.test.ts | 23 +- server/tests/composio-broker-seam.test.ts | 13 + .../tests/composio-connection-kinds.test.ts | 4 + server/tests/composio-connections.test.ts | 4 + server/tests/composio-grants-for.test.ts | 249 ++++++++++++++++++ server/tests/gate-grant-is-local.test.ts | 4 + .../mcp-grant-removal.integration.test.ts | 4 + server/tests/plugin-store.integration.test.ts | 8 + .../remote-bot-handoff.integration.test.ts | 4 + server/tests/shared-brokered-routes.test.ts | 4 + .../shared-brokered-store.integration.test.ts | 4 + .../shared-gated-mode.integration.test.ts | 4 + .../tests/shared-wiring.integration.test.ts | 4 + server/tests/store-scheme-sites.test.ts | 4 + server/tsconfig.json | 4 + 21 files changed, 564 insertions(+), 6 deletions(-) create mode 100644 server/tests/composio-grants-for.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 4e077d5f7..7a994cc68 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -198,10 +198,17 @@ export function createArcadeClient(apiKey: string): { isConnected: notYet("micro-task 6.2", "isConnected"), revoke: notYet("micro-task 6.2", "revoke"), revokeAccount: notYet("micro-task 6.2", "revokeAccount"), - // 6.3: grantsFor and authorizationStatus (the read-only status pair; neither is on this seam - // yet, so there is no stub to throw). `authorizationStatus` must narrow to - // `{ status, userId, scopes }` and nothing else: the raw `auth.status` response carries - // `context.token`, a live provider access token, plus the person's full profile. + // 6.3: the read-only status pair. Both are DECLARED here because the seam requires them — + // `grantsFor` is a member every broker answers, and `authorizations` is the sub-surface Arcade + // alone runs, so a null here would say Arcade mints nothing to poll, which is false. The + // bodies are 6.3's. `status` must narrow to `{ status, userId, scopes }` and nothing else: the + // raw `auth.status` response carries `context.token`, a live provider access token, plus the + // person's full profile. + grantsFor: notYet("micro-task 6.3", "grantsFor"), + authorizations: { + status: notYet("micro-task 6.3", "authorizations.status"), + confirmReturn: notYet("micro-task 6.3", "authorizations.confirmReturn"), + }, }; return { actions, broker }; } diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 1300caaef..c4826f60b 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -328,6 +328,76 @@ export type BrokerApp = { */ export type AuthConfigOutcome = "created" | "standing" | "not-needed"; +/** + * What one account holds at one app, in THIS DEPLOYMENT'S OWN VOCABULARY rather than a vendor's row. + * + * `action` PLUS A STATE PLUS `scopes` IS OPENBOT'S SHAPE AND NOT ARCADE'S. A provider that grants + * per action answers one of these per action because that is what it knows; a provider that grants + * per app answers the same state once per action because that is what IT knows, and neither is + * reading the other's rows. The four states are what a caller can act on — run it, send them to a + * consent, tell an operator to set something up, say that nothing could be read — and `unknown` + * carries its own reason because a state nobody could read is a real answer and not a defensive one. + * + * A DEGENERATE ANSWER IS NOT AN ABSENT ONE, which is why {@link ConnectedAppBroker.grantsFor} is on + * the seam rather than behind a nullable sub-surface. Composio's auth config fixes the scope set + * when it is created, so every action in a toolkit shares one state and one scope list; N identical + * entries is the honest rendering of a toolkit-wide grant, not a stub standing in for a missing + * answer. + */ +export type GrantState = + | { action: string; state: "authorized"; scopes: string[] } + | { action: string; state: "needs-consent"; scopes: string[] } + | { action: string; state: "needs-operator"; missing: string[] } + | { action: string; state: "unknown"; reason: string }; + +/** + * The CORRELATED, POLLABLE AUTHORIZATION FLOW one provider runs and the other does not. + * + * THE LINE THIS SUB-SURFACE FALLS ON IS "THIS BROKER RUNS A CORRELATED, POLLABLE AUTHORIZATION + * FLOW" — NOT "THIS BROKER CAN START A CONSENT", WHICH BOTH DO. {@link ConnectedAppBroker.authorize} + * therefore stays top-level and must not be tidied in here: its caller is the Composio connect flow + * in `routes.ts`, and moving it would either break that route or force Composio to declare this + * sub-surface, which would make the nullability mean nothing. + * + * ASKING THE OTHER PROVIDER FOR THE STATUS OF A MINT IS A CATEGORY ERROR RATHER THAN A HARD + * QUESTION. Composio mints no correlated authorization to poll, and its return leg is an OAuth + * callback with state handled in `oauth.ts` and never through the broker. A member that existed only + * to throw would be a provider branch wearing a type's clothes — which is the charge this seam + * already met once at `accountName`, two implementations of one member answering different + * questions. That charge does NOT apply to {@link ConnectedAppBroker.grantsFor}, which does not + * throw. + * + * `status` IS NAMED `status` AND NOT `authorizationStatus`: the sub-surface supplies the noun. + */ +export type AuthorizationFlow = { + /** + * Where one minted authorization has got to, by the id the mint answered with. + * + * NARROWED TO THESE THREE FIELDS AND NOTHING ELSE. A provider's own status reply carries the live + * access token it issued and the person's full profile beside it, and neither is something a seam + * this deployment reads in routes and stores should be able to hand on by accident. + */ + status( + id: string, + ): Promise<{ status: string; userId: string | null; scopes: string[] }>; + /** + * Match somebody coming back from the provider's screen to the authorization they left through. + * + * `handle` IS AN OPAQUE STRING THIS SEAM DOES NOT INTERPRET. It is minted by the adapter, carried + * by the return leg and handed back here to be recognised; `flow_id` is Arcade's word for it and + * stops at the adapter. Nothing here parses it, compares it to a scheme or reads a provider out of + * it. + * + * `{ mismatch: true }` RATHER THAN A THROW, because a handle that belongs to somebody else is an + * ANSWER: it is exactly the case a return leg has to be able to refuse quietly, and a caller + * cannot tell it from an outage if both arrive as exceptions. + */ + confirmReturn(q: { + handle: string; + vendorUserId: string; + }): Promise<{ authorizationId: string } | { mismatch: true }>; +}; + /** * What this deployment needs of Composio's broker, and nothing more. * @@ -355,6 +425,42 @@ export type ConnectedAppBroker = { * is the behaviour it always had, made explicit here rather than changed. */ grantIsLocal: boolean; + /** + * The correlated authorization flow, or a DECLARED `null` where this broker runs none. + * + * THE FIELD IS REQUIRED AND THE `null` IS A STATEMENT RATHER THAN AN INFERRED ABSENCE. A broker + * literal that omits it fails to type-check, which is the whole of what this costs and the whole + * of what it buys. {@link ConnectedAppBroker.grantIsLocal} is the shape being copied and its + * docblock argues for it in its own words: an optional-shaped capability read with `?.` or + * `=== undefined` answers for a broker that never declared anything, which is how a gate and its + * exemption came to key on different things at `store.ts:1631` and the exemption failed OPEN — + * a call ran for somebody who had connected nothing. Nine stubs omitting `grantIsLocal` were the + * evidence; an optional member here would reopen that exact shape one field along. + * + * SO A ROUTE REACHES IT WITH `if (!broker.authorizations)` — A PRESENCE TEST ON A VALUE THE + * BROKER DECLARED, and never a vendor name. Which flow a provider runs is the provider's own + * answer, stated once on the object it hands over. + * + * WHAT IT IS AND IS NOT ABOUT: see {@link AuthorizationFlow}. The line is "this broker runs a + * correlated, pollable authorization flow", NOT "this broker can start a consent" — both do that, + * through {@link ConnectedAppBroker.authorize}, which stays top-level. + */ + authorizations: AuthorizationFlow | null; + /** + * What this account holds at this app, action by action. + * + * THE NEUTRAL SEAM BOTH PROVIDERS ANSWER, and it is one question rather than two: what does this + * account hold at this app. A provider that grants per action answers from its own rows; a + * provider whose auth config fixes the scope set when it is created answers the same state once + * per action, carrying that config's scopes. The second answer is DEGENERATE AND DEGENERATE IS + * NOT ABSENT — it is what a toolkit-wide grant honestly looks like through this vocabulary, and + * an implementation that threw instead would be two implementations of one member answering + * different questions, which is the real bug this seam already found at `accountName`. + * + * {@link GrantState} IS THIS DEPLOYMENT'S VOCABULARY AND NOT A VENDOR'S ROW SHAPE. Nothing here + * is copied across from a provider's listing; see that type. + */ + grantsFor(q: { toolkit: string; account: AccountRef }): Promise; /** Every app the catalogue offers, which is what an administrator chooses from. */ listApps(): Promise; /** diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index fdfad1988..9027b14f4 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -8,6 +8,7 @@ import { type ComposioBroker, type ConnectedAppBroker, type FieldScheme, + type GrantState, flagOf, isFieldScheme, } from "./broker"; @@ -223,6 +224,15 @@ type VendorAuthConfig = { id?: unknown; name?: unknown; status?: unknown; + /** + * What the config was created to ask for, where Composio publishes it, and `unknown` because it + * is copied across verbatim like the three above. + * + * IT IS A PROPERTY OF THE CONFIG RATHER THAN OF AN ACCOUNT, which is the whole of why + * {@link ComposioBroker.grantsFor}'s answer is one state for a toolkit: the scope set is fixed + * when the config is created and every account connected against it holds exactly that set. + */ + scopes?: unknown; }; /** @@ -1097,8 +1107,35 @@ type CheckedAuthConfig = { id: string; name: string; status: unknown; + /** The scope set this config was created with, as {@link scopesOf} could read it. */ + scopes: string[]; }; +/** + * The scope names on an auth config row, and the EMPTY LIST for everything else. + * + * NOT A REFUSAL, BECAUSE ITS ONE READER REPORTS RATHER THAN ACTS. Every other field this file + * reads off a config decides something destructive or something a person is sent through — which + * id to delete, which config to mint a consent against — so a shape that cannot be read is a thing + * to stop on. Scopes are carried on a {@link GrantState}, which is a description handed to a page: + * Composio publishes none at all for a key-scheme config, so "no scope set published" is the + * ordinary case and not a fault, and an app somebody is plainly connected to must not become + * unreportable because the field arrived as a string. + * + * ONE PASS, AND THE NON-STRINGS ARE DROPPED RATHER THAN RENDERED. A scope is a name sent to a + * provider; `String({})` is not one, and a list half of whose entries were invented here is worse + * than a shorter list. + */ +function scopesOf(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const names: string[] = []; + for (const entry of value) { + const name = textOf(entry); + if (name !== null) names.push(name); + } + return names; +} + /** * That status as the WORD it is, so the test and the sentence about it are about the same value. * @@ -1240,7 +1277,12 @@ function readableConfigs( return; } - const config: CheckedAuthConfig = { id, name, status: row.status }; + const config: CheckedAuthConfig = { + id, + name, + status: row.status, + scopes: scopesOf(row.scopes), + }; if (slot === undefined) { slotOf.set(id, settled.length); settled.push(config); @@ -3008,6 +3050,54 @@ export function buildComposioClient( // Composio's grant is held by this deployment. Its existing behaviour, declared; see // {@link ConnectedAppBroker.grantIsLocal} for why this is a property and not a comparison. grantIsLocal: true, + // Composio runs no correlated, pollable authorization flow: it mints nothing to poll, and its + // return leg is an OAuth callback with state, handled in `oauth.ts` and never through this + // seam. Declared rather than omitted; see {@link ConnectedAppBroker.authorizations}. + authorizations: null, + /** + * What this person holds at this app, said once per action because that is what Composio knows. + * + * DEGENERATE AND NOT ABSENT, which is why this is implemented rather than thrown. An auth + * config fixes its scope set when it is created and a connection is made against the config, + * so there is exactly one answer for the toolkit: either this person has an account on it or + * they have not, and the scopes are the config's. Reporting that answer once per action is the + * honest rendering of a toolkit-wide grant through {@link GrantState}, which is this + * deployment's vocabulary rather than any vendor's row shape. The alternative — a throw — + * would make one seam member answer two different questions depending on who implemented it, + * which is the defect this file already carried at {@link ComposioBroker.accountName}. + * + * THE ACCOUNT LISTING GOES FIRST, AND THE ORDER IS THE CHEAP QUESTION BEFORE THE DEAR ONES. It + * is the one call that can settle on a single row — see `accountsFor`'s `enough` — while the + * config listing and the action listing both read every page. + * + * SCOPES COME FROM THIS DEPLOYMENT'S OWN CONFIGS AND FROM NO OTHER. `configsFor` returns the + * operator's hand-made configs beside ours, and a scope set nobody here chose is not what this + * person's connection was minted against — the same distinction {@link madeHere} draws for + * every destructive caller, drawn here for a descriptive one. The names are deduplicated and + * ordered so that two readings of one unchanged deployment are one answer. + */ + async grantsFor({ toolkit, account }): Promise { + const held = await accountsFor(account.vendorUserId, toolkit, CONNECTED, { + enough: (found) => found.length > 0, + }); + const state = held.length > 0 ? "authorized" : "needs-consent"; + + const { ours } = await configsFor(toolkit); + const scopes = [ + ...new Set(ours.flatMap((config) => config.scopes)), + ].sort(); + + const published = await actions.listActions(toolkit, { + limit: LISTING_LIMIT, + }); + // A fresh array per entry: one shared list would let a caller mutating its first grant's + // scopes rewrite every other grant in the answer. + return published.map((action) => ({ + action: action.slug, + state, + scopes: [...scopes], + })); + }, /** * The catalogue, from memory where this process asked for it less than ten minutes ago. * diff --git a/server/tests/account-mode.integration.test.ts b/server/tests/account-mode.integration.test.ts index 947ded15b..02c49305e 100644 --- a/server/tests/account-mode.integration.test.ts +++ b/server/tests/account-mode.integration.test.ts @@ -32,6 +32,11 @@ const rules: { }[] = []; const broker = { + // This stub stands in for Composio, whose grant is held locally and which runs no + // correlated authorization flow; see `ConnectedAppBroker.grantIsLocal` and `.authorizations`. + grantIsLocal: true, + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 658615955..6e605cbb7 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -17,6 +17,17 @@ const serverId = `arcade-attempt-store-${suite}`; const broker: ConnectedAppBroker = { // Arcade holds the grant at the vendor, not locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: false, + // Arcade runs the correlated, pollable flow, so this stub DECLARES the sub-surface rather + // than a null; nothing here reaches either member. See `ConnectedAppBroker.authorizations`. + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/brokered-app-provider.integration.test.ts b/server/tests/brokered-app-provider.integration.test.ts index 9bafae30c..a5577982f 100644 --- a/server/tests/brokered-app-provider.integration.test.ts +++ b/server/tests/brokered-app-provider.integration.test.ts @@ -51,6 +51,10 @@ function storeFor( const broker: ComposioBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: unasked("read grants"), listApps: unasked("list"), ensureAuthConfig: async () => ensure, deleteAuthConfig: unasked("delete"), diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 144c16a46..634107897 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -3038,6 +3038,12 @@ describe("what a vendor failure becomes on its way out of the seam", () => { because: "Minting the link failed with nothing said. Nobody was sent anywhere and nothing was attached, so trying again is the whole of the advice.", }, + { + method: "grantsFor", + kind: "an outage", + because: + "The account listing did not answer. Nothing is granted or spent by asking what somebody holds, and the page this fills in has the route's own advice — the deployment's key, the vendor's status page — to show instead.", + }, { method: "isConnected", kind: "an outage", @@ -3145,6 +3151,21 @@ describe("what a vendor failure becomes on its way out of the seam", () => { returnUrl: RETURN_URL, }), }, + { + // The account listing is the first of the three `grantsFor` reads, and the only one a + // failure here can reach: the config listing and the action listing come after it. + method: "grantsFor", + vendor: (raise) => ({ connectedAccounts: { list: raise } }), + ask: ({ broker }) => + broker.grantsFor({ + account: { + holder: "person", + userId: "user_1", + vendorUserId: "user_1", + }, + toolkit: "gmail", + }), + }, { method: "isConnected", vendor: (raise) => ({ connectedAccounts: { list: raise } }), @@ -3278,7 +3299,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { Object.entries(seam.broker) .filter(([, member]) => typeof member !== "function") .map(([name]) => name), - ).toEqual(["grantIsLocal"]); + ).toEqual(["grantIsLocal", "authorizations"]); }); for (const seamCase of SEAM_CASES) { diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index c4817b236..4e154c2bb 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -157,6 +157,19 @@ describe("grantIsLocal", () => { ); }); + /** + * `authorizations` is the same kind of value one field along: a DECLARED capability, read with a + * presence test — `if (!broker.authorizations)` — and never with a vendor name. Composio runs no + * correlated, pollable authorization flow, and saying so is not the same as leaving the field + * out: an omission would make the field optional in practice, which is the failed-open shape + * `grantIsLocal` exists to close. + */ + test("Composio declares no correlated authorization flow", () => { + expect(createComposioClient("never-dialled").broker.authorizations).toBe( + null, + ); + }); + test("a broker handed an action it cannot use ignores it rather than throwing", async () => { const { broker } = createComposioClient("never-dialled"); const original = globalThis.fetch; diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index d5d3b270d..cd88b278f 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -661,6 +661,10 @@ let brokerAsks: string[] = []; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => { throw new Error("nothing here lists the catalogue"); }, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index 5f1f37e0d..c1f8a383d 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -682,6 +682,10 @@ let configWasStanding = false; const broker: ComposioBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: unasked("read grants"), listApps: unasked("list the catalogue"), ensureAuthConfig: async (config) => { // Named by app AND kind, because the kind is what decides which config is created: an enable diff --git a/server/tests/composio-grants-for.test.ts b/server/tests/composio-grants-for.test.ts new file mode 100644 index 000000000..a748d3a00 --- /dev/null +++ b/server/tests/composio-grants-for.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from "bun:test"; +import type { + AuthorizationFlow, + ConnectedAppBroker, + GrantState, +} from "../src/plugins/broker"; +import { + type ComposioVendor, + buildComposioClient, +} from "../src/plugins/composio-adapter"; + +/** + * `grantsFor` through Composio, whose answer is DEGENERATE AND IS STILL AN ANSWER. + * + * The seam asks one question both providers answer: what does this account hold at this app. + * Composio's auth config fixes the scope set when it is created, so every action in a toolkit + * shares one state and one scope list — and reporting that state once per action is the honest + * rendering of a toolkit-wide grant, not a stub standing in for a missing implementation. The + * assertions below are therefore about SAMENESS as much as about the state: one entry per action, + * the same verdict on each, and the scopes read off this deployment's own config rather than + * invented per action. + * + * This file is named in `server/tsconfig.json`'s `include`, because the last describe is a TYPE + * test and a `@ts-expect-error` inside an unchecked directory asserts nothing at all. + */ + +/** A vendor method nothing in a given test should reach, which says so rather than answering. */ +const refuse = (what: string) => async (): Promise => { + throw new Error(`${what} should not have been called in this test.`); +}; + +/** The person every fixture here asks about. */ +const ACCOUNT = { + holder: "person", + userId: "user_1", + vendorUserId: "user_1", +} as const; + +/** + * A vendor answering the three listings `grantsFor` reads, and refusing everything else. + * + * The three are the whole of the implementation's reach: whether this person holds an account for + * the app, which of this deployment's own auth configs stand for it (and what scopes they carry), + * and which actions the app publishes. A fourth call is a change in what the method does, and it + * names itself here rather than passing. + */ +function vendorWith(parts: { + accounts: { id?: unknown; data?: unknown }[]; + configs: { + id?: unknown; + name?: unknown; + status?: unknown; + scopes?: unknown; + }[]; + actions: { slug: string }[]; +}): ComposioVendor { + return { + tools: { + list: async () => ({ items: parts.actions, next_cursor: null }), + getRawComposioToolBySlug: refuse("tools.getRawComposioToolBySlug"), + execute: refuse("tools.execute"), + }, + toolkits: { + list: refuse("toolkits.list"), + retrieve: refuse("toolkits.retrieve"), + }, + authConfigs: { + list: async () => ({ items: parts.configs, nextCursor: null }), + create: refuse("authConfigs.create"), + delete: refuse("authConfigs.delete"), + }, + connectedAccounts: { + list: async () => ({ items: parts.accounts, nextCursor: null }), + link: refuse("connectedAccounts.link"), + create: refuse("connectedAccounts.create"), + delete: refuse("connectedAccounts.delete"), + }, + }; +} + +describe("Composio's grantsFor", () => { + test("answers one state per action, the same one for each, carrying the config's scopes", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [{ id: "ca_1" }], + configs: [ + { + id: "ac_1", + name: "gmail (OpenBot)", + status: "ENABLED", + scopes: ["gmail.readonly", "gmail.send"], + }, + ], + actions: [ + { slug: "GMAIL_FETCH_EMAILS" }, + { slug: "GMAIL_SEND_EMAIL" }, + { slug: "GMAIL_CREATE_LABEL" }, + ], + }), + ); + + const grants = await broker.grantsFor({ + toolkit: "gmail", + account: ACCOUNT, + }); + + expect(grants).toEqual([ + { + action: "GMAIL_FETCH_EMAILS", + state: "authorized", + scopes: ["gmail.readonly", "gmail.send"], + }, + { + action: "GMAIL_SEND_EMAIL", + state: "authorized", + scopes: ["gmail.readonly", "gmail.send"], + }, + { + action: "GMAIL_CREATE_LABEL", + state: "authorized", + scopes: ["gmail.readonly", "gmail.send"], + }, + ]); + // The sameness is the claim, asserted on its own so that a per-action answer invented here + // fails even if each entry happens to be well-formed. + expect(new Set(grants.map((grant) => grant.state))).toEqual( + new Set(["authorized"]), + ); + }); + + test("answers needs-consent for each action where this person has connected nothing", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [], + configs: [ + { + id: "ac_1", + name: "gmail (OpenBot)", + status: "ENABLED", + scopes: ["gmail.readonly"], + }, + ], + actions: [{ slug: "GMAIL_FETCH_EMAILS" }, { slug: "GMAIL_SEND_EMAIL" }], + }), + ); + + const grants = await broker.grantsFor({ + toolkit: "gmail", + account: ACCOUNT, + }); + + expect(grants).toEqual([ + { + action: "GMAIL_FETCH_EMAILS", + state: "needs-consent", + scopes: ["gmail.readonly"], + }, + { + action: "GMAIL_SEND_EMAIL", + state: "needs-consent", + scopes: ["gmail.readonly"], + }, + ]); + }); + + /** + * A config publishing no scope set is an empty list rather than a refusal, because this method + * REPORTS rather than acts: Composio publishes no scopes for a key-scheme config at all, and an + * app whose grant cannot be described scope by scope is still an app this person is connected to. + */ + test("carries an empty scope list where the config publishes none", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [{ id: "ca_1" }], + configs: [ + { id: "ac_1", name: "firecrawl (OpenBot)", status: "ENABLED" }, + ], + actions: [{ slug: "FIRECRAWL_SCRAPE" }], + }), + ); + + expect( + await broker.grantsFor({ toolkit: "firecrawl", account: ACCOUNT }), + ).toEqual([ + { action: "FIRECRAWL_SCRAPE", state: "authorized", scopes: [] }, + ]); + }); +}); + +/** + * `authorizations` is a DECLARED value, and Composio's declaration is `null`. + * + * Read with a presence test and never with a vendor name: the point of the field is that the + * broker says what it runs, on the object it hands over. + */ +describe("Composio's authorizations", () => { + test("declares null rather than offering a flow it does not run", () => { + const { broker } = buildComposioClient( + vendorWith({ accounts: [], configs: [], actions: [] }), + ); + + expect(broker.authorizations).toBeNull(); + }); +}); + +/** + * THE NULLABILITY IS ONLY WORTH ANYTHING IF OMITTING THE FIELD FAILS, which is a claim about the + * type and therefore a claim only `tsc` can settle — hence this file in `server/tsconfig.json`. + * + * An optional-shaped capability is the exact shape that failed open at `store.ts:1631`: nine broker + * stubs omitted `grantIsLocal`, the gate's exemption read `undefined`, and a call ran for somebody + * who had connected nothing. The two assertions below are the two halves of that — a literal + * missing `authorizations` must not compile, and a literal declaring it `null` must. + */ +describe("the declared sub-surface", () => { + test("a broker literal omitting authorizations does not compile", () => { + const members = { + grantIsLocal: true, + grantsFor: async (): Promise => [], + listApps: async () => [], + ensureAuthConfig: async () => "not-needed" as const, + deleteAuthConfig: async () => {}, + authorize: async () => ({ redirectUrl: "https://openbot.test/x" }), + isConnected: async () => false, + revoke: async () => false, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => null, + }; + + // @ts-expect-error `authorizations` is required: a broker must SAY whether it runs a + // correlated, pollable authorization flow, and an omission is not an answer. + const omitted: ConnectedAppBroker = members; + const declared: ConnectedAppBroker = { ...members, authorizations: null }; + + expect(omitted.grantIsLocal).toBe(true); + expect(declared.authorizations).toBeNull(); + }); + + test("a broker that runs the flow declares the sub-surface instead", () => { + const flow: AuthorizationFlow = { + status: async () => ({ status: "completed", userId: null, scopes: [] }), + confirmReturn: async () => ({ mismatch: true }), + }; + + expect(flow).not.toBeNull(); + }); +}); diff --git a/server/tests/gate-grant-is-local.test.ts b/server/tests/gate-grant-is-local.test.ts index 35f279da3..8270e9e3d 100644 --- a/server/tests/gate-grant-is-local.test.ts +++ b/server/tests/gate-grant-is-local.test.ts @@ -34,6 +34,10 @@ const VENDOR_ID = `openbot-deployment:acme-${suite}:R`; * name is identical in all three tests. */ const brokerStub: Omit = { + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/mcp-grant-removal.integration.test.ts b/server/tests/mcp-grant-removal.integration.test.ts index 32a9a260e..175ace5f9 100644 --- a/server/tests/mcp-grant-removal.integration.test.ts +++ b/server/tests/mcp-grant-removal.integration.test.ts @@ -25,6 +25,10 @@ const botId = `agent_grant_removal_${suite}`; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => undefined, deleteAuthConfig: async () => undefined, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 46947e698..be918af8b 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6614,6 +6614,10 @@ test("enabling an app writes a brokered row, and asks for its auth config first" const broker: ComposioBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a + // broker to SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: unasked("read grants"), listApps: unasked("list the catalogue"), ensureAuthConfig: async (config) => { asked.push(config); @@ -6789,6 +6793,10 @@ function brokerSpy(answers: { broker: { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a + // broker to SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: unasked("read grants"), listApps: unasked("list the catalogue"), ensureAuthConfig: asked( "ensureAuthConfig", diff --git a/server/tests/remote-bot-handoff.integration.test.ts b/server/tests/remote-bot-handoff.integration.test.ts index 8dbaaf620..e1752a391 100644 --- a/server/tests/remote-bot-handoff.integration.test.ts +++ b/server/tests/remote-bot-handoff.integration.test.ts @@ -37,6 +37,10 @@ const ALL = [REMOTE, BUILT_IN, OTHER_REMOTE]; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => undefined, deleteAuthConfig: async () => undefined, diff --git a/server/tests/shared-brokered-routes.test.ts b/server/tests/shared-brokered-routes.test.ts index 7a8bd4b6d..aa315e370 100644 --- a/server/tests/shared-brokered-routes.test.ts +++ b/server/tests/shared-brokered-routes.test.ts @@ -41,6 +41,10 @@ const rules: { const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index fe0735a87..19fd41d13 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -34,6 +34,10 @@ const revoked: string[] = []; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/shared-gated-mode.integration.test.ts b/server/tests/shared-gated-mode.integration.test.ts index c7b710341..79dcfc082 100644 --- a/server/tests/shared-gated-mode.integration.test.ts +++ b/server/tests/shared-gated-mode.integration.test.ts @@ -26,6 +26,10 @@ const VENDOR_ID = `openbot-deployment:acme-${suite}:R`; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/shared-wiring.integration.test.ts b/server/tests/shared-wiring.integration.test.ts index d0b7fd819..4c7021ca2 100644 --- a/server/tests/shared-wiring.integration.test.ts +++ b/server/tests/shared-wiring.integration.test.ts @@ -27,6 +27,10 @@ const app = `wired-${suite}`; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts index d22161bca..98c94ca88 100644 --- a/server/tests/store-scheme-sites.test.ts +++ b/server/tests/store-scheme-sites.test.ts @@ -60,6 +60,10 @@ const unasked = (what: string) => async (): Promise => { function storeFor(provider: BrokerProvider) { const broker: ComposioBroker = { grantIsLocal: true, + // Nothing here reads either member; both are declared because the seam requires a broker to + // SAY what it runs — see `ConnectedAppBroker.authorizations`. + authorizations: null, + grantsFor: unasked("read grants"), listApps: unasked("list"), ensureAuthConfig: async () => "created", deleteAuthConfig: unasked("delete"), diff --git a/server/tsconfig.json b/server/tsconfig.json index 11571fb56..4dfaa97cd 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -41,6 +41,10 @@ "scripts", "../scripts/composio-smoke.ts", "tests/store-provider-required.test.ts", + // `tests/composio-grants-for.test.ts` FOR THE SAME REASON, one seam member along: its last + // describe is a TYPE test. A broker literal omitting `authorizations` must not compile, and the + // `@ts-expect-error` that says so is checked by `tsc` or by nothing at all. + "tests/composio-grants-for.test.ts", "tests/arcade-transport-registry.test.ts", "tests/db-client-address.test.ts" ] From 1e8304267a3ecf1960fcdc7688466dbea336add8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:04:51 +0200 Subject: [PATCH 044/232] style: format the ts-expect-error coverage guard Line wrapping only, produced by `bun run format` after 2c5f8d2. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/ts-expect-error-coverage.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/tests/ts-expect-error-coverage.test.ts b/server/tests/ts-expect-error-coverage.test.ts index 935a347df..f56f38b45 100644 --- a/server/tests/ts-expect-error-coverage.test.ts +++ b/server/tests/ts-expect-error-coverage.test.ts @@ -93,5 +93,7 @@ test("the directive pattern takes both comment forms and leaves prose", () => { const d = "@ts-expect" + "-error"; expect(DIRECTIVE.test(` // ${d} the call`)).toBe(true); expect(DIRECTIVE.test(`x(); /* ${d} */`)).toBe(true); - expect(DIRECTIVE.test(" * the ts-expect-error directive is checked by tsc")).toBe(false); + expect( + DIRECTIVE.test(" * the ts-expect-error directive is checked by tsc"), + ).toBe(false); }); From b8c95f767b24513e671f60a9c71554de31ec487f Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:06:56 +0200 Subject: [PATCH 045/232] feat(arcade): the mint pair, authorize for the named action and confirmReturn Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 53 +++++- server/tests/arcade-adapter-authorize.test.ts | 170 ++++++++++++++++++ 2 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 server/tests/arcade-adapter-authorize.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 7a994cc68..21884425d 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -182,16 +182,41 @@ export function createArcadeClient(apiKey: string): { user_id: account.vendorUserId, })) { const info = row.provider_user_info as - | { name?: unknown; login?: unknown } - | null - | undefined; + { name?: unknown; login?: unknown } | null | undefined; const name = textOf(info?.name) ?? textOf(info?.login); if (name) return name; } return null; }, - // 6.1: authorize, and confirm_user (the mint pair; confirm_user is not on this seam yet) - authorize: notYet("micro-task 6.1", "authorize"), + // The mint. Stays a TOP-LEVEL member: the Composio connect flow calls it from routes.ts, so it + // cannot move behind the nullable `authorizations` sub-surface. + authorize: async ({ account, action }) => { + if (!action) { + throw new BrokerRefusalError( + "Arcade grants per action, so an authorization needs the action it is for and none was given.", + ); + } + // NEVER SEND `next_uri`. THIS LOOKS LIKE A BUG AND IS A MEASUREMENT. The SDK declares the + // field (`ToolAuthorizeParams.next_uri`), but against the live API it refuses EVERY value and + // the mint answers `500 "error starting authorization challenge: invalid next URI"`: an app + // page, the verifier's own origin, `cloud.arcade.dev` and a localhost address alike. Do not + // "fix" this by passing a return address. + // + // `returnUrl` IS ACCEPTED AND IGNORED, for the same reason: the landing page comes from the + // attempt row (spec D18), not from a parameter this vendor will take. + const answer = await client.tools.authorize({ + tool_name: action, + user_id: account.vendorUserId, + }); + // The url is a bearer capability: it goes back to the caller and nowhere else. + const url = answer.url; + if (typeof url !== "string" || url === "") { + throw new Error( + "Arcade answered an authorization with no url to visit.", + ); + } + return { redirectUrl: url }; + }, // 6.2: isConnected, revoke, revokeAccount (the connection-lifecycle trio). // `admin.userConnections.delete` takes `connection_id` (`ac_...`), NOT the `id` (`at_...`) the // listing prints first: measured, `at_...` answers 500 and `ac_...` answers 204. @@ -207,7 +232,23 @@ export function createArcadeClient(apiKey: string): { grantsFor: notYet("micro-task 6.3", "grantsFor"), authorizations: { status: notYet("micro-task 6.3", "authorizations.status"), - confirmReturn: notYet("micro-task 6.3", "authorizations.confirmReturn"), + // `handle` is opaque to the seam; `flow_id` is Arcade's word for it and stops in this file. + // A `user_mismatch` answer is an ANSWER, so it surfaces as `{ mismatch: true }`; any other + // failure is an outage or a bug and is rethrown untouched. + confirmReturn: async ({ handle, vendorUserId }) => { + try { + const answer = await client.auth.confirmUser({ + flow_id: handle, + user_id: vendorUserId, + }); + return { authorizationId: answer.auth_id }; + } catch (error) { + if (normalizeArcadeError(error)?.name === "user_mismatch") { + return { mismatch: true }; + } + throw error; + } + }, }, }; return { actions, broker }; diff --git a/server/tests/arcade-adapter-authorize.test.ts b/server/tests/arcade-adapter-authorize.test.ts new file mode 100644 index 000000000..283e0e9ef --- /dev/null +++ b/server/tests/arcade-adapter-authorize.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import Arcade, { APIError } from "@arcadeai/arcadejs"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { BrokerRefusalError } from "../src/plugins/broker"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** Prototype stubs BEFORE construction; `new Arcade({ apiKey })` opens no socket. */ +type Methods = Record unknown>; +const protoOf = (pick: (c: Arcade) => object): Methods => + Object.getPrototypeOf( + pick(new Arcade({ apiKey: "never-dialled" })), + ) as Methods; + +const restores: (() => void)[] = []; +function stub( + on: Methods, + name: string, + impl: (...args: unknown[]) => unknown, +) { + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} +afterEach(() => { + while (restores.length) restores.pop()?.(); + useArcadeClient(null); +}); + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +describe("arcade adapter authorize", () => { + test("sends tool_name and user_id with the action's case intact", async () => { + const seen: unknown[] = []; + stub( + protoOf((c) => c.tools), + "authorize", + (params) => { + seen.push(params); + return Promise.resolve({ + id: "au_1", + url: "https://consent.example/x", + }); + }, + ); + const { broker } = createArcadeClient("never-dialled"); + const out = await broker.authorize({ + account, + toolkit: "Github", + returnUrl: "https://app.example/settings", + action: "Github.CreateIssue", + }); + expect(seen).toHaveLength(1); + const params = seen[0] as Record; + expect(params.tool_name).toBe("Github.CreateIssue"); + expect(params.user_id).toBe("vendor-u1"); + expect(out).toEqual({ redirectUrl: "https://consent.example/x" }); + }); + + test("with no action it refuses without dialling", async () => { + const seen: unknown[] = []; + stub( + protoOf((c) => c.tools), + "authorize", + (params) => { + seen.push(params); + return Promise.resolve({}); + }, + ); + const { broker } = createArcadeClient("never-dialled"); + await expect( + broker.authorize({ + account, + toolkit: "Github", + returnUrl: "https://app.example/settings", + }), + ).rejects.toBeInstanceOf(BrokerRefusalError); + expect(seen).toHaveLength(0); + }); + + test("the outgoing authorize params contain no next_uri key at all", async () => { + const seen: unknown[] = []; + stub( + protoOf((c) => c.tools), + "authorize", + (params) => { + seen.push(params); + return Promise.resolve({ + id: "au_1", + url: "https://consent.example/x", + }); + }, + ); + const { broker } = createArcadeClient("never-dialled"); + await broker.authorize({ + account, + toolkit: "Github", + returnUrl: "https://app.example/settings", + action: "Github.CreateIssue", + }); + const params = seen[0] as object; + expect(Object.keys(params)).not.toContain("next_uri"); + expect("next_uri" in params).toBe(false); + expect(JSON.stringify(params)).not.toContain("app.example"); + }); +}); + +describe("arcade adapter confirmReturn", () => { + const confirmUser = (impl: (...args: unknown[]) => unknown) => + stub( + protoOf((c) => c.auth), + "confirmUser", + impl, + ); + + test("returns the authorizationId, sending the handle as flow_id", async () => { + const seen: unknown[] = []; + confirmUser((params) => { + seen.push(params); + return Promise.resolve({ auth_id: "au_9" }); + }); + const { broker } = createArcadeClient("never-dialled"); + const out = await broker.authorizations?.confirmReturn({ + handle: "flow-abc", + vendorUserId: "vendor-u1", + }); + expect(out).toEqual({ authorizationId: "au_9" }); + expect(seen[0]).toEqual({ flow_id: "flow-abc", user_id: "vendor-u1" }); + }); + + test("reports user_mismatch as { mismatch: true }, not a throw", async () => { + confirmUser(() => + Promise.reject( + new APIError( + 400, + { name: "user_mismatch", message: "wrong user" }, + "400", + new Headers(), + ), + ), + ); + const { broker } = createArcadeClient("never-dialled"); + const out = await broker.authorizations?.confirmReturn({ + handle: "flow-abc", + vendorUserId: "someone-else", + }); + expect(out).toEqual({ mismatch: true }); + }); + + test("any other failure still throws", async () => { + confirmUser(() => + Promise.reject( + new APIError(500, { name: "boom", message: "x" }, "500", new Headers()), + ), + ); + const { broker } = createArcadeClient("never-dialled"); + await expect( + broker.authorizations?.confirmReturn({ + handle: "flow-abc", + vendorUserId: "vendor-u1", + }), + ).rejects.toMatchObject({ status: 500 }); + }); +}); From a0e943e05daed25192de554bc76e160f2e3bae0e Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:09:32 +0200 Subject: [PATCH 046/232] feat(arcade): wire exactly one provider in the server entrypoint selectProviderWiring installs one transport client (the other explicitly null) and hands one broker, plus its provider name, to the store, the routes and the logo backfill. Activation is refused by brokered_connections row count, and a named provider with no key fails at boot. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/app.ts | 3 +- server/src/index.ts | 58 +++++++++---- server/src/plugins/provider-wiring.ts | 73 ++++++++++++++++ server/src/plugins/routes.ts | 3 +- server/tests/provider-selection.test.ts | 106 ++++++++++++++++++++++++ 5 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 server/src/plugins/provider-wiring.ts diff --git a/server/src/app.ts b/server/src/app.ts index bd387f00f..320fe0b99 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -95,6 +95,7 @@ import type { OnboardingStore } from "./people/onboarding"; import { MAX_PAGE, type PeopleStore } from "./people/store"; import type { AccountModeSwitch } from "./plugins/account-mode"; import type { ComposioBroker } from "./plugins/broker"; +import type { BrokerProvider } from "./plugins/access"; import { createPluginRoutes } from "./plugins/routes"; import { createSharedUseRoutes } from "./plugins/shared-use-routes"; import type { SharedUseStore } from "./plugins/shared-use-store"; @@ -378,7 +379,7 @@ export function createApp( * configured, which is the correct degraded behaviour: a deployment with no Composio API key has * no app directory to offer, rather than one that lists apps nobody can connect. */ - composio?: { broker: ComposioBroker }, + composio?: { broker: ComposioBroker; provider: BrokerProvider }, /** Native model OAuth stays server-side; callers hold only a separate local bearer. */ modelProviderProxy?: ModelProviderProxy, userPreferences?: UserPreferencesStore, diff --git a/server/src/index.ts b/server/src/index.ts index ba2cbad3d..7002778a9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,7 +6,7 @@ import { IntelligenceAgentRunner, } from "@copilotkit/runtime/v2"; import { serve } from "bun"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt"; import { DICTATION_HTTP_IDLE_SECONDS } from "../../shared/dictation"; @@ -155,6 +155,7 @@ import { } from "./credentials"; import { createDatabase } from "./db/client"; import { + brokeredConnections, channelAgents, channelMemberships, channels, @@ -192,8 +193,10 @@ import { createPasswordStore } from "./passwords/store"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { createAccountModeSwitch } from "./plugins/account-mode"; -import { activeProvider } from "./plugins/broker"; +import { activeProvider, assertActivatable } from "./plugins/broker"; import { useRoutineTools } from "./plugins/builtin-routines"; +import { useArcadeClient } from "./plugins/arcade"; +import { createArcadeClient } from "./plugins/arcade-adapter"; import { useComposioClient } from "./plugins/composio"; import { createComposioClient } from "./plugins/composio-adapter"; import { backfillBrokeredLogos } from "./plugins/logos"; @@ -202,6 +205,7 @@ import { createSharedUseGate, createSharedUseStore, } from "./plugins/shared-use-store"; +import { selectProviderWiring } from "./plugins/provider-wiring"; import { createPluginStore } from "./plugins/store"; import { grantedSkills, @@ -668,10 +672,34 @@ const sandboxedStore = createSandboxedStore(database, bootAuditStore); * Installing a client built from an absent key would turn all of that into a vendor error at first * use, which sends an operator looking for a broken Composio instead of at their own configuration. */ -const composio = config.composioApiKey - ? createComposioClient(config.composioApiKey) - : null; -if (composio) useComposioClient(composio.actions); +/* + * EXACTLY ONE PROVIDER, DECIDED ONCE. `selectProviderWiring` answers which transport client is + * installed and which broker goes on to the store and the routes; the other client is installed as + * an explicit `null`, so a deployment holding both keys (legal, and needed to end accounts at the + * old vendor) never has two seams live. It refuses a named provider whose key is unset. + */ +const providerWiring = selectProviderWiring(config, { + composio: createComposioClient, + arcade: createArcadeClient, +}); +useComposioClient(providerWiring.install.composio); +useArcadeClient(providerWiring.install.arcade); +const wiredProvider = providerWiring.provider; +const composio = + wiredProvider && providerWiring.broker + ? { broker: providerWiring.broker, provider: wiredProvider } + : null; +/* + * Activation is refused while the OTHER provider still holds connection rows. Keyed on rows, never + * on keys: having both keys set is the expected state while a sunset is in progress. + */ +if (wiredProvider) { + const [{ count: otherProviderRows }] = await database + .select({ count: sql`count(*)::int` }) + .from(brokeredConnections) + .where(ne(brokeredConnections.provider, wiredProvider)); + assertActivatable(wiredProvider, otherProviderRows); +} /** * The private sign-in form and the Passwords vault. A Bot asks to be signed in; the person answers @@ -793,13 +821,15 @@ const accountModes = createAccountModeSwitch({ // Logo metadata is optional; a vendor outage must not prevent the API from starting. if (composio) { - void backfillBrokeredLogos(database, "composio", composio.broker).catch( - () => { - console.warn( - "Composio app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.", - ); - }, - ); + void backfillBrokeredLogos( + database, + composio.provider, + composio.broker, + ).catch(() => { + console.warn( + "Composio app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.", + ); + }); } /** @@ -2987,7 +3017,7 @@ const app = createApp( // The app directory, behind the same client the plugin store and the transport already share. // Absent without a key, which leaves the routes reporting no broker rather than listing apps // nobody could connect. - composio ? { broker: composio.broker } : undefined, + composio ?? undefined, process.env.OPENBOT_MODEL_OAUTH_FILE?.trim() ? createProviderOAuthProxy(process.env.OPENBOT_MODEL_OAUTH_FILE.trim()) : undefined, diff --git a/server/src/plugins/provider-wiring.ts b/server/src/plugins/provider-wiring.ts new file mode 100644 index 000000000..971d9bd4f --- /dev/null +++ b/server/src/plugins/provider-wiring.ts @@ -0,0 +1,73 @@ +import type { ArcadeActions } from "./arcade"; +import type { ComposioActions } from "./composio"; +import { activeProvider, type ConnectedAppBroker } from "./broker"; +import type { BrokerProvider } from "./access"; + +/** + * The entrypoint's provider decision, with the vendor constructors passed in so this file imports + * no vendor package and a test can answer without booting the server or making a request. + */ +type ClientFactory = (apiKey: string) => { + actions: Actions; + broker: ConnectedAppBroker; +}; + +export type ProviderWiring = { + /** The one active provider, or `null` for a deployment that legitimately has no broker. */ + provider: BrokerProvider | null; + /** The one broker handed to the store and the routes; `null` exactly when `provider` is. */ + broker: ConnectedAppBroker | null; + /** Both transport seams, always both: the one that is not active is explicitly `null`. */ + install: { composio: ComposioActions | null; arcade: ArcadeActions | null }; +}; + +/** + * Chooses ONE provider from configuration and builds only that provider's client. + * + * `activeProvider` answers `null` in three situations: no keys, both keys with nothing named, and + * a named provider whose key is unset. The first two are legal states. The third is an operator + * typo, and degrading it to a deployment with no broker would send them looking for a broken + * vendor instead of at their own environment, so it is refused here, naming the variables and + * never a value. + */ +export function selectProviderWiring( + c: { + composioApiKey?: string; + arcadeApiKey?: string; + brokerProvider?: BrokerProvider; + }, + create: { + composio: ClientFactory; + arcade: ClientFactory; + }, +): ProviderWiring { + const provider = activeProvider(c); + if (provider === null && c.brokerProvider !== undefined) { + const keyName = + c.brokerProvider === "arcade" ? "ARCADE_API_KEY" : "COMPOSIO_API_KEY"; + throw new Error( + `OPENBOT_BROKER_PROVIDER=${c.brokerProvider} names a provider whose ${keyName} is not set. Set ${keyName}, or change or unset OPENBOT_BROKER_PROVIDER.`, + ); + } + if (provider === "arcade" && c.arcadeApiKey !== undefined) { + const client = create.arcade(c.arcadeApiKey); + return { + provider, + broker: client.broker, + install: { composio: null, arcade: client.actions }, + }; + } + if (provider === "composio" && c.composioApiKey !== undefined) { + const client = create.composio(c.composioApiKey); + return { + provider, + broker: client.broker, + install: { composio: client.actions, arcade: null }, + }; + } + return { + provider: null, + broker: null, + install: { composio: null, arcade: null }, + }; +} diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index d3aca75af..a8d1ffaea 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -6,6 +6,7 @@ import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; import { reasonWithoutStatement } from "../db/query-failure"; import type { AccountModeSwitch } from "./account-mode"; +import type { BrokerProvider } from "./access"; import { type BrokerApp, type BrokerField, @@ -201,7 +202,7 @@ export function createPluginRoutes( * trap `connect` documents applies here with one more argument in it — every parameter from that * position on is optional, so a misplaced one typechecks and quietly does nothing. */ - composio?: { broker: ComposioBroker }, + composio?: { broker: ComposioBroker; provider: BrokerProvider }, /** * Who may use an app's one shared account, and the switch that makes an app Shared or Personal. * diff --git a/server/tests/provider-selection.test.ts b/server/tests/provider-selection.test.ts index ccb0a1647..a4e19b941 100644 --- a/server/tests/provider-selection.test.ts +++ b/server/tests/provider-selection.test.ts @@ -5,6 +5,7 @@ import { BrokerRefusalError, brokerSentence, } from "../src/plugins/broker"; +import { selectProviderWiring } from "../src/plugins/provider-wiring"; /** * Which broker is active, decided from configuration alone: no network, no database. @@ -84,3 +85,108 @@ describe("assertActivatable", () => { expect(sentence).toContain("sunset"); }); }); + +/** + * The entrypoint's decision, extracted so no test boots the server: which transport client is + * installed, which broker is passed on, and what happens to a named provider whose key is unset. + * Fake factories stand in for the vendor constructors, so nothing here can make a request. + */ +describe("selectProviderWiring", () => { + function factories() { + const calls: string[] = []; + const composioActions = { tag: "composio-actions" } as never; + const arcadeActions = { tag: "arcade-actions" } as never; + const composioBroker = { tag: "composio-broker" } as never; + const arcadeBroker = { tag: "arcade-broker" } as never; + return { + calls, + composioActions, + arcadeActions, + composioBroker, + arcadeBroker, + create: { + composio: (key: string) => { + calls.push(`composio:${key.length}`); + return { actions: composioActions, broker: composioBroker }; + }, + arcade: (key: string) => { + calls.push(`arcade:${key.length}`); + return { actions: arcadeActions, broker: arcadeBroker }; + }, + }, + }; + } + + test("only the Arcade key installs the Arcade client and leaves the Composio seam null", () => { + const f = factories(); + const wiring = selectProviderWiring({ arcadeApiKey: "a" }, f.create); + expect(wiring.provider).toBe("arcade"); + expect(wiring.install.arcade).toBe(f.arcadeActions); + expect(wiring.install.composio).toBeNull(); + expect(wiring.broker).toBe(f.arcadeBroker); + expect(f.calls).toEqual(["arcade:1"]); + }); + + test("only the Composio key is today's behaviour: its client, its broker, Arcade null", () => { + const f = factories(); + const wiring = selectProviderWiring({ composioApiKey: "c" }, f.create); + expect(wiring.provider).toBe("composio"); + expect(wiring.install.composio).toBe(f.composioActions); + expect(wiring.install.arcade).toBeNull(); + expect(wiring.broker).toBe(f.composioBroker); + expect(f.calls).toEqual(["composio:1"]); + }); + + test("both keys with nothing named installs neither and builds neither", () => { + const f = factories(); + const wiring = selectProviderWiring( + { composioApiKey: "c", arcadeApiKey: "a" }, + f.create, + ); + expect(wiring.provider).toBeNull(); + expect(wiring.install.composio).toBeNull(); + expect(wiring.install.arcade).toBeNull(); + expect(wiring.broker).toBeNull(); + expect(f.calls).toEqual([]); + }); + + test("both keys with a provider named installs only the named one", () => { + const f = factories(); + const wiring = selectProviderWiring( + { composioApiKey: "c", arcadeApiKey: "a", brokerProvider: "arcade" }, + f.create, + ); + expect(wiring.provider).toBe("arcade"); + expect(wiring.install.composio).toBeNull(); + expect(wiring.install.arcade).toBe(f.arcadeActions); + expect(f.calls).toEqual(["arcade:1"]); + }); + + test("no keys at all is a legal no-broker deployment", () => { + const f = factories(); + const wiring = selectProviderWiring({}, f.create); + expect(wiring.provider).toBeNull(); + expect(wiring.broker).toBeNull(); + expect(wiring.install).toEqual({ composio: null, arcade: null }); + }); + + test("a named provider whose key is unset is surfaced, not treated as no broker", () => { + const f = factories(); + expect(() => + selectProviderWiring( + { composioApiKey: "c", brokerProvider: "arcade" }, + f.create, + ), + ).toThrow(/OPENBOT_BROKER_PROVIDER=arcade.*ARCADE_API_KEY/); + expect(() => + selectProviderWiring( + { arcadeApiKey: "a", brokerProvider: "composio" }, + f.create, + ), + ).toThrow(/OPENBOT_BROKER_PROVIDER=composio.*COMPOSIO_API_KEY/); + expect(() => + selectProviderWiring({ brokerProvider: "arcade" }, f.create), + ).toThrow(/ARCADE_API_KEY/); + expect(f.calls).toEqual([]); + }); +}); From 7486a9da02bb428f23e4b9f70673d6ae7f05fefe Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:10:06 +0200 Subject: [PATCH 047/232] style(arcade): format the adapter's mint pair Line wrapping only, produced by `bun run format` after b8c95f7. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 21884425d..53b30b6c9 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -182,7 +182,9 @@ export function createArcadeClient(apiKey: string): { user_id: account.vendorUserId, })) { const info = row.provider_user_info as - { name?: unknown; login?: unknown } | null | undefined; + | { name?: unknown; login?: unknown } + | null + | undefined; const name = textOf(info?.name) ?? textOf(info?.login); if (name) return name; } From bd5f7d30a3b0367efecd4fbd8847e798eb1f3e04 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:10:53 +0200 Subject: [PATCH 048/232] fix(arcade): name the active vendor in the logo-backfill warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning said "Composio app logos…" on every deployment, which is wrong once Arcade can be the active provider. The vendor label comes from BROKERED_PROFILE, so this is the provider record rather than a vendor-name comparison the guard would catch. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 7002778a9..e3d675088 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -194,6 +194,7 @@ import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { createAccountModeSwitch } from "./plugins/account-mode"; import { activeProvider, assertActivatable } from "./plugins/broker"; +import { BROKERED_PROFILE } from "./plugins/access"; import { useRoutineTools } from "./plugins/builtin-routines"; import { useArcadeClient } from "./plugins/arcade"; import { createArcadeClient } from "./plugins/arcade-adapter"; @@ -827,7 +828,7 @@ if (composio) { composio.broker, ).catch(() => { console.warn( - "Composio app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.", + `${BROKERED_PROFILE[composio.provider].vendor} app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.`, ); }); } From b1128d355ac36e14d423ae2346d4c7d6bbaa88fa Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:12:22 +0200 Subject: [PATCH 049/232] feat(arcade): the connection-lifecycle trio, and accountName filtered by provider isConnected, revoke and revokeAccount go through admin.userConnections. delete is passed the row's connection_id (ac_...), never id (at_...): the listing prints id first, the SDK says nothing, and measured against the live API at_ answers 500 and ac_ answers 204. revoke attempts every delete, throws if any fail, and answers false only when nothing was found. The toolkit to provider_id mapping is one shared helper, connectionsFor. It reads each row's own provider_id off the admin.userConnections listing the adapter already performs (case-insensitive), so it costs no extra vendor call. accountName now uses it: it used to return the first named row of any app, so a person connected to two apps saw the other app's name. It now matches Composio's accountsFor(toolkit) and answers null when no row of this app carries a name. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 78 ++++++++-- .../tests/arcade-adapter-connections.test.ts | 146 ++++++++++++++++++ server/tests/arcade-adapter.test.ts | 9 +- 3 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 server/tests/arcade-adapter-connections.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 53b30b6c9..076f25e89 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -126,6 +126,44 @@ async function listApps(client: Arcade): Promise { const FIELDS_REFUSAL = "Arcade has no per-person field entry: what it asks for is a deployment-wide secret (admin.secrets, e.g. GITHUB_SERVER_URL) that an operator sets once for everybody, so a form asking a person to type one would collect a value that is not theirs and share it with everyone. Set it in the Arcade dashboard instead."; +type ConnectionRow = Arcade.Admin.UserConnectionResponse; + +/** + * THE ONE PLACE the toolkit to `provider_id` mapping lives. It is read off each row's own + * `provider_id` in the `admin.userConnections` listing the adapter already performs (no extra + * vendor call), compared case-insensitively. A row for another app is never returned. + */ +async function connectionsFor( + client: Arcade, + vendorUserId: string, + toolkit: string, +): Promise { + const wanted = toolkit.toLowerCase(); + const rows: ConnectionRow[] = []; + for await (const row of client.admin.userConnections.list({ + user_id: vendorUserId, + })) { + if (textOf(row.provider_id)?.toLowerCase() === wanted) rows.push(row); + } + return rows; +} + +/** + * DELETE TAKES `connection_id` (`ac_...`), NOT `id` (`at_...`). A user-connection row carries both + * and the listing prints `id` FIRST; the SDK declares `delete(id: string)` and does not say which. + * Measured against the live API: `at_...` answers 500, `ac_...` answers 204. Reading the obvious + * field fails only when somebody presses Disconnect, the worst time to find out. + */ +async function deleteConnection( + client: Arcade, + connectionId: string | undefined, +): Promise { + if (!connectionId) { + throw new Error("Arcade connection has no connection_id to delete."); + } + await client.admin.userConnections.delete(connectionId); +} + const notYet = (task: string, member: string) => () => Promise.reject(new Error(`${member} is not implemented yet (${task}).`)); @@ -177,10 +215,14 @@ export function createArcadeClient(apiKey: string): { Promise.reject(new BrokerRefusalError(FIELDS_REFUSAL)), connectWithFields: () => Promise.reject(new BrokerRefusalError(FIELDS_REFUSAL)), - accountName: async ({ account }) => { - for await (const row of client.admin.userConnections.list({ - user_id: account.vendorUserId, - })) { + accountName: async ({ account, toolkit }) => { + // Filtered by the row's own provider, as Composio's `accountsFor(..., toolkit, ...)` is: a + // person connected to two apps must never be shown the OTHER app's name on this app's screen. + for (const row of await connectionsFor( + client, + account.vendorUserId, + toolkit, + )) { const info = row.provider_user_info as | { name?: unknown; login?: unknown } | null @@ -219,12 +261,28 @@ export function createArcadeClient(apiKey: string): { } return { redirectUrl: url }; }, - // 6.2: isConnected, revoke, revokeAccount (the connection-lifecycle trio). - // `admin.userConnections.delete` takes `connection_id` (`ac_...`), NOT the `id` (`at_...`) the - // listing prints first: measured, `at_...` answers 500 and `ac_...` answers 204. - isConnected: notYet("micro-task 6.2", "isConnected"), - revoke: notYet("micro-task 6.2", "revoke"), - revokeAccount: notYet("micro-task 6.2", "revokeAccount"), + // 6.2: the connection-lifecycle trio. + isConnected: async ({ account, toolkit }) => + (await connectionsFor(client, account.vendorUserId, toolkit)).length > 0, + // Answers what was REQUESTED: true when something was found and every delete was accepted, + // false when there was nothing. Every delete is attempted, then ANY failure throws: a partial + // ask must not read as `false` ("nothing there") nor as a clean `true`. + revoke: async ({ account, toolkit }) => { + const rows = await connectionsFor(client, account.vendorUserId, toolkit); + if (rows.length === 0) return false; + const outcomes = await Promise.allSettled( + rows.map((row) => deleteConnection(client, row.connection_id)), + ); + const failed = outcomes.filter((o) => o.status === "rejected"); + if (failed.length > 0) { + throw new Error( + `Arcade revoke failed for ${failed.length} of ${rows.length} connections.`, + { cause: (failed[0] as PromiseRejectedResult).reason }, + ); + } + return true; + }, + revokeAccount: (accountId) => deleteConnection(client, accountId), // 6.3: the read-only status pair. Both are DECLARED here because the seam requires them — // `grantsFor` is a member every broker answers, and `authorizations` is the sub-surface Arcade // alone runs, so a null here would say Arcade mints nothing to poll, which is false. The diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts new file mode 100644 index 000000000..8a78befa1 --- /dev/null +++ b/server/tests/arcade-adapter-connections.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import Arcade from "@arcadeai/arcadejs"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** Prototype stubs BEFORE construction; `new Arcade({ apiKey })` opens no socket. */ +type Methods = Record unknown>; +const connectionsProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).admin.userConnections, + ) as Methods; + +const restores: (() => void)[] = []; +function stub(name: string, impl: (...args: unknown[]) => unknown) { + const on = connectionsProto(); + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} +afterEach(() => { + while (restores.length) restores.pop()?.(); + useArcadeClient(null); +}); + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +const row = (n: number, provider: string, info?: unknown) => ({ + id: `at_${n}`, + connection_id: `ac_${n}`, + provider_id: provider, + provider_user_info: info, +}); + +function listing(rows: unknown[]) { + stub("list", () => + (async function* () { + for (const r of rows) yield r; + })(), + ); +} + +describe("arcade adapter connection lifecycle", () => { + test("isConnected is true on a non-empty list and false on an empty one", async () => { + const rows: unknown[] = [row(1, "github")]; + listing(rows); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Github" })).toBe(true); + rows.length = 0; + expect(await broker.isConnected({ account, toolkit: "Github" })).toBe( + false, + ); + }); + + test("isConnected ignores another app's connection", async () => { + listing([row(1, "google")]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Github" })).toBe( + false, + ); + }); + + test("delete is passed the ac_ connection_id, never the at_ id", async () => { + listing([row(7, "github")]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.revoke({ account, toolkit: "Github" })).toBe(true); + expect(deleted).toEqual(["ac_7"]); + expect(String(deleted[0]).startsWith("at_")).toBe(false); + }); + + test("revokeAccount deletes by the id it is handed", async () => { + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + await broker.revokeAccount("ac_9"); + expect(deleted).toEqual(["ac_9"]); + }); + + test("revoke answers false on nothing-found and deletes nothing", async () => { + listing([row(1, "google")]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.revoke({ account, toolkit: "Github" })).toBe(false); + expect(deleted).toEqual([]); + }); + + test("revoke THROWS when one of several deletes fails, after trying them all", async () => { + listing([row(1, "github"), row(2, "github"), row(3, "github")]); + const tried: unknown[] = []; + stub("delete", (id) => { + tried.push(id); + return id === "ac_2" + ? Promise.reject(new Error("500")) + : Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + let outcome: unknown = "returned"; + try { + outcome = await broker.revoke({ account, toolkit: "Github" }); + } catch (error) { + outcome = error; + } + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toMatch(/1 of 3/); + expect(tried).toEqual(["ac_1", "ac_2", "ac_3"]); + }); + + test("accountName never answers another app's name", async () => { + listing([ + row(1, "github", {}), + row(2, "google", { name: "Grace" }), + row(3, "github", { name: "Ada" }), + ]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.accountName({ account, toolkit: "GitHub" })).toBe( + "Ada", + ); + expect(await broker.accountName({ account, toolkit: "Google" })).toBe( + "Grace", + ); + }); + + test("accountName is null, not the second app's name, when the first carries none", async () => { + listing([row(1, "github", {}), row(2, "google", { name: "Grace" })]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.accountName({ account, toolkit: "Github" })).toBeNull(); + }); +}); diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index 439a25231..abf132a26 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -176,13 +176,16 @@ describe("createArcadeClient", () => { ); const { broker } = createArcadeClient("never-dialled"); const ask = () => broker.accountName({ account, toolkit: "Github" }); - rows.push({ provider_user_info: { name: "Ada", login: "ada1" } }); + rows.push({ + provider_id: "github", + provider_user_info: { name: "Ada", login: "ada1" }, + }); expect(await ask()).toBe("Ada"); rows.length = 0; - rows.push({ provider_user_info: { login: "ada1" } }); + rows.push({ provider_id: "github", provider_user_info: { login: "ada1" } }); expect(await ask()).toBe("ada1"); rows.length = 0; - rows.push({ provider_user_info: {} }); + rows.push({ provider_id: "github", provider_user_info: {} }); expect(await ask()).toBeNull(); }); From 71111ed63be2bce9d1438c04af223103a2480190 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:19:38 +0200 Subject: [PATCH 050/232] feat(arcade): the read-only status pair, narrowed by type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grantsFor` lists the toolkit with the account's `user_id` — the one place a listing is per-person — and reads `requirements.secrets[].met` BEFORE `requirements.authorization`, because `requirements.met` is false for two unrelated reasons and a consent screen cannot set `GITHUB_SERVER_URL`. Anything the listing does not say clearly answers `unknown` with its reason rather than a pessimistic `needs-consent`, since not knowing is not the same as not connected. `authorizations.status` reads three fields off `auth.status` and drops the response inside the adapter. That inverts this codebase's carry-the-vendor-error -as-`cause` convention on purpose: the response carries `context.token`, a live provider access token, the person's full `user_info` profile, and a bearer `url`. A status response looks like metadata, which is how the throwaway harness wrote the token to a file on its first run — so the narrowing is the return type, not a comment asking the next maintainer to remember. `listTools` is untouched: it still sends no `user_id` and still ignores `requirements.met`. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 144 ++++++++++++- server/tests/arcade-adapter-status.test.ts | 239 +++++++++++++++++++++ 2 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 server/tests/arcade-adapter-status.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 076f25e89..44908e29e 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -4,6 +4,7 @@ import { BrokerRefusalError, type BrokerApp, type ConnectedAppBroker, + type GrantState, } from "./broker"; /** @@ -164,8 +165,116 @@ async function deleteConnection( await client.admin.userConnections.delete(connectionId); } -const notYet = (task: string, member: string) => () => - Promise.reject(new Error(`${member} is not implemented yet (${task}).`)); +const record = (value: unknown): Record | null => + value !== null && typeof value === "object" + ? (value as Record) + : null; + +/** + * One listed action read into this deployment's vocabulary, or nothing when it has no name. + * + * `requirements.met` IS A BOOLEAN WITH TWO UNRELATED CAUSES AND IS NEVER READ HERE. It is false + * both when the person has not consented and when a deployment-wide secret is unset — the live + * listing shows `Github` requiring `GITHUB_SERVER_URL`, which an operator sets once for everybody + * and which no amount of consenting moves. Sending somebody to a consent screen over that one + * sends them somewhere that cannot help them, so the secrets are read FIRST and answer + * `needs-operator` with the keys they name. + * + * `unknown` IS ITS OWN STATE RATHER THAN A PESSIMISTIC `needs-consent`, because not knowing is not + * the same as not connected: telling somebody to authorize something they already authorized sends + * them round twice. So anything the listing does not say clearly answers `unknown` with the reason. + */ +function grantStateOf(item: unknown): GrantState[] { + const tool = record(item); + const action = textOf(tool?.qualified_name); + if (!action) return []; + const requirements = record(tool?.requirements); + + const rawSecrets = requirements?.secrets; + const missing: string[] = []; + let secretUnreadable = false; + for (const raw of Array.isArray(rawSecrets) ? rawSecrets : []) { + const secret = record(raw); + const key = textOf(secret?.key); + if (!key) { + secretUnreadable = true; + continue; + } + if (secret?.met === false) missing.push(key); + else if (secret?.met !== true) secretUnreadable = true; + } + if (missing.length > 0) return [{ action, state: "needs-operator", missing }]; + if (secretUnreadable) { + return [ + { + action, + state: "unknown", + reason: + "Arcade listed a deployment secret for this action without saying whether it is set.", + }, + ]; + } + + const authorization = record(requirements?.authorization); + if (!authorization) { + return [ + { + action, + state: "unknown", + reason: + "Arcade's listing named no authorization for this action, so what this account holds could not be read.", + }, + ]; + } + const oauth2 = record(authorization.oauth2)?.scopes; + const scopes = Array.isArray(oauth2) + ? oauth2.filter((s): s is string => typeof s === "string") + : []; + const tokenStatus = textOf(authorization.token_status); + const status = textOf(authorization.status); + if (tokenStatus === "completed" || (!tokenStatus && status === "active")) { + return [{ action, state: "authorized", scopes }]; + } + if ( + tokenStatus === "not_started" || + tokenStatus === "pending" || + tokenStatus === "failed" || + status === "inactive" + ) { + return [{ action, state: "needs-consent", scopes }]; + } + return [ + { + action, + state: "unknown", + reason: + "Arcade's listing gave this action's authorization neither a status nor a token status.", + }, + ]; +} + +/** + * What one account holds at one app, action by action. + * + * THIS IS THE ONE PLACE A `user_id` IS SENT ON A LISTING. The transport's `listTools` sends none + * and reads no per-person state off the answer, because a catalogue is the same for everybody and a + * per-person listing would make it one person's. Here the question IS about a person, so the + * account's vendor id goes out with it. + */ +async function grantsFor( + client: Arcade, + vendorUserId: string, + toolkit: string, +): Promise { + const page = await client.tools.list({ toolkit, user_id: vendorUserId }); + const items = page?.items; + if (!Array.isArray(items)) { + throw new Error( + `Arcade did not answer with an action list for ${toolkit}, so what this account holds there could not be read.`, + ); + } + return items.flatMap(grantStateOf); +} /** * The Arcade client, in the shape `createComposioClient` returns. `new Arcade({ apiKey })` opens no @@ -289,9 +398,36 @@ export function createArcadeClient(apiKey: string): { // bodies are 6.3's. `status` must narrow to `{ status, userId, scopes }` and nothing else: the // raw `auth.status` response carries `context.token`, a live provider access token, plus the // person's full profile. - grantsFor: notYet("micro-task 6.3", "grantsFor"), + grantsFor: ({ account, toolkit }) => + grantsFor(client, account.vendorUserId, toolkit), authorizations: { - status: notYet("micro-task 6.3", "authorizations.status"), + /** + * Where one minted authorization has got to — THREE FIELDS READ OUT AND THE RESPONSE OBJECT + * DROPPED HERE, inside the adapter, so nothing outside this file ever holds it. + * + * THIS DELIBERATELY INVERTS THIS CODEBASE'S CARRY-THE-VENDOR-ERROR-AS-`cause` CONVENTION. + * That convention is right everywhere else: a vendor's own words are what a maintainer needs + * when a call fails. It is wrong here, because `auth.status` answers with `context.token` — a + * LIVE, WORKING provider access token for the person who just consented — and their full + * `user_info` profile beside it, while `url` is a bearer capability of its own. A status + * response LOOKS LIKE METADATA, which is exactly how the throwaway harness that proved this + * flow came to write the token to a file on its first run. + * + * So the protection is the RETURN TYPE and not a comment asking the next person to remember: + * what is not returned cannot be logged, attached as a `cause`, or written into an audit + * payload by code that never sees it. Do not widen this, and do not add `url`. + */ + status: async (id) => { + const answer = await client.auth.status({ id }); + const scopes = answer.scopes; + return { + status: textOf(answer.status) ?? "unknown", + userId: textOf(answer.user_id) ?? null, + scopes: Array.isArray(scopes) + ? scopes.filter((s): s is string => typeof s === "string") + : [], + }; + }, // `handle` is opaque to the seam; `flow_id` is Arcade's word for it and stops in this file. // A `user_mismatch` answer is an ANSWER, so it surfaces as `{ mismatch: true }`; any other // failure is an outage or a bug and is rethrown untouched. diff --git a/server/tests/arcade-adapter-status.test.ts b/server/tests/arcade-adapter-status.test.ts new file mode 100644 index 000000000..209ebff21 --- /dev/null +++ b/server/tests/arcade-adapter-status.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import Arcade from "@arcadeai/arcadejs"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** Prototype stubs BEFORE construction; `new Arcade({ apiKey })` opens no socket. */ +type Methods = Record unknown>; +const protoOf = (pick: (c: Arcade) => unknown): Methods => + Object.getPrototypeOf( + pick(new Arcade({ apiKey: "never-dialled" })), + ) as Methods; + +const restores: (() => void)[] = []; +function stub( + pick: (c: Arcade) => unknown, + name: string, + impl: (...args: unknown[]) => unknown, +) { + const on = protoOf(pick); + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} +afterEach(() => { + while (restores.length) restores.pop()?.(); + useArcadeClient(null); +}); + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +/** The listing calls, so a test can assert what went out with them. */ +const listCalls: unknown[] = []; +function listing(items: unknown[]) { + listCalls.length = 0; + stub( + (c) => c.tools, + "list", + (query) => { + listCalls.push(query); + return Promise.resolve({ items }); + }, + ); +} + +const tool = (name: string, requirements: unknown) => ({ + fully_qualified_name: `${name}@1.0.0`, + qualified_name: name, + name: name.split(".")[1], + toolkit: { name: "Github" }, + requirements, +}); + +describe("grantsFor maps a toolkit listing to this deployment's vocabulary", () => { + test("sends the user_id it was given and maps all four states", async () => { + listing([ + tool("Github.SetStarred", { + met: true, + authorization: { + provider_id: "github", + status: "active", + token_status: "completed", + oauth2: { scopes: ["repo", "user"] }, + }, + secrets: [], + }), + tool("Github.ListStargazers", { + met: false, + authorization: { + provider_id: "github", + status: "inactive", + token_status: "not_started", + oauth2: { scopes: ["repo"] }, + }, + secrets: [], + }), + tool("Github.CreateIssue", { + met: false, + authorization: { + provider_id: "github", + status: "active", + token_status: "completed", + oauth2: { scopes: ["repo"] }, + }, + // The live listing: an operator sets this once for everybody and no amount + // of consenting moves it. + secrets: [ + { key: "GITHUB_SERVER_URL", met: false, status_reason: "unset" }, + ], + }), + tool("Github.CountStargazers", { met: false }), + ]); + + const { broker } = createArcadeClient("never-dialled"); + const grants = await broker.grantsFor({ toolkit: "Github", account }); + + expect(listCalls).toHaveLength(1); + expect(listCalls[0]).toMatchObject({ + toolkit: "Github", + user_id: "vendor-u1", + }); + + expect(grants).toEqual([ + { + action: "Github.SetStarred", + state: "authorized", + scopes: ["repo", "user"], + }, + { + action: "Github.ListStargazers", + state: "needs-consent", + scopes: ["repo"], + }, + { + action: "Github.CreateIssue", + state: "needs-operator", + missing: ["GITHUB_SERVER_URL"], + }, + { + action: "Github.CountStargazers", + state: "unknown", + reason: expect.any(String), + }, + ]); + }); + + test("an unreadable authorization is unknown, never a pessimistic needs-consent", async () => { + listing([ + // An authorization block with neither a status nor a token_status: the listing + // says neither "connected" nor "not connected". + tool("Github.WhoAmI", { + met: false, + authorization: { provider_id: "github", oauth2: { scopes: ["user"] } }, + secrets: [], + }), + ]); + + const { broker } = createArcadeClient("never-dialled"); + const grants = await broker.grantsFor({ toolkit: "Github", account }); + + expect(grants).toHaveLength(1); + expect(grants[0]?.state).toBe("unknown"); + expect(grants[0]?.state).not.toBe("needs-consent"); + expect(grants[0]).toEqual({ + action: "Github.WhoAmI", + state: "unknown", + reason: expect.any(String), + }); + }); +}); + +/** + * THE LEAK TEST. `auth.status` answers with a LIVE provider access token and the person's full + * profile; the narrowing is a type, not a rule somebody has to remember. + */ +const TOKEN = "gho_16C7e42F292c6912E7710c838347Ae178B4a"; +const PROFILE = { + email: "ada@example.com", + name: "Ada Lovelace", + login: "ada", + id: 4711, +}; + +function statusAnswers(answer: unknown) { + stub( + (c) => c.auth, + "status", + () => Promise.resolve(answer), + ); +} + +describe("authorizations.status narrows and drops the rest", () => { + const raw = { + id: "auth_123", + status: "completed", + user_id: "vendor-u1", + scopes: ["repo", "user"], + provider_id: "github", + url: "https://cloud.arcade.dev/approve/abc123", + context: { token: TOKEN, user_info: PROFILE }, + }; + + test("returns exactly the three fields", async () => { + statusAnswers(raw); + const { broker } = createArcadeClient("never-dialled"); + const flow = broker.authorizations; + if (!flow) throw new Error("Arcade must declare an authorization flow."); + + const narrowed = await flow.status("auth_123"); + expect(Object.keys(narrowed).sort()).toEqual([ + "scopes", + "status", + "userId", + ]); + expect(narrowed).toEqual({ + status: "completed", + userId: "vendor-u1", + scopes: ["repo", "user"], + }); + }); + + test("the token, the profile and the url do not survive the narrowing", async () => { + statusAnswers(raw); + const { broker } = createArcadeClient("never-dialled"); + const flow = broker.authorizations; + if (!flow) throw new Error("Arcade must declare an authorization flow."); + + const serialized = JSON.stringify(await flow.status("auth_123")); + expect(serialized).not.toContain(TOKEN); + expect(serialized).not.toContain("gho_"); + expect(serialized).not.toContain(PROFILE.email); + expect(serialized).not.toContain(PROFILE.name); + expect(serialized).not.toContain(PROFILE.login); + expect(serialized).not.toContain("user_info"); + expect(serialized).not.toContain("token"); + // An authorization url is a bearer capability and is not one of the three fields. + expect(serialized).not.toContain(raw.url); + expect(serialized).not.toContain("provider_id"); + }); + + test("a missing status and user_id answer a string and a null, not undefined", async () => { + statusAnswers({ context: { token: TOKEN, user_info: PROFILE } }); + const { broker } = createArcadeClient("never-dialled"); + const flow = broker.authorizations; + if (!flow) throw new Error("Arcade must declare an authorization flow."); + + const narrowed = await flow.status("auth_123"); + expect(narrowed.userId).toBeNull(); + expect(typeof narrowed.status).toBe("string"); + expect(narrowed.scopes).toEqual([]); + expect(JSON.stringify(narrowed)).not.toContain(TOKEN); + }); +}); From 4573362273dcd32741a8038937c535448f3aac96 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:36:38 +0200 Subject: [PATCH 051/232] refactor(arcade): name the audit payload's auth provider apart from the broker Rename the completion audit payload key and its input field to authProvider so it cannot be confused with brokered_connections.provider. Correct the docblock on where the value comes from, and give two route tests the provider they omit. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 11 +++++++---- .../arcade-completion-transaction.integration.test.ts | 10 ++++++---- server/tests/composio-connection-kinds.test.ts | 2 +- server/tests/shared-brokered-routes.test.ts | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 254f4f548..9636cd82f 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -6142,8 +6142,11 @@ export function createPluginStore(options: PluginStoreOptions) { * an error. What the trail gets is the action the attempt was for and the scopes that came * back, which is exactly what is needed later to explain a tool call refused for want of a * scope — the question {@link mcpUserCredentials.scope} exists to answer for a `user-oauth` - * grant. `provider` is the authorization provider this deployment ASKED for, carried down from - * the attempt rather than read back out of the vendor's reply. + * grant. `authProvider` is the authorization provider this deployment ASKED for (`google`, say, as + * distinct from the broker, `brokered_connections.provider`). The caller resolves it from the + * app's own row at freshness-revalidation time and passes it in; the attempt row has no + * provider column to carry it. It is never read back out of a vendor reply, and the narrowed + * `status` return of D31 makes that structurally impossible. */ async completeAuthorizationAttempt(input: { actorId: string; @@ -6152,7 +6155,7 @@ export function createPluginStore(options: PluginStoreOptions) { account: AccountRef; action: string; scopes: string[]; - provider: string; + authProvider: string; }): Promise { await database.transaction(async (transaction) => { /* @@ -6245,7 +6248,7 @@ export function createPluginStore(options: PluginStoreOptions) { scope: input.scopes.join(" "), // The action the attempt was for: what this grant was obtained in order to call. action: input.action, - provider: input.provider, + authProvider: input.authProvider, reconnected: Boolean(existing), holder: input.account.holder, }, diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index 609c899b7..5f62e6b00 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -197,7 +197,7 @@ describe("completeAuthorizationAttempt", () => { account: account(a), action: "Google.SendEmail", scopes: ["gmail.send", "gmail.readonly"], - provider: "google", + authProvider: "google", }); const connections = await connectionsFor(app); @@ -216,6 +216,8 @@ describe("completeAuthorizationAttempt", () => { expect(payload.scope).toBe("gmail.send gmail.readonly"); expect(payload.server).toBe(app); expect(payload.holder).toBe("person"); + expect(payload.authProvider).toBe("google"); + expect(payload).not.toHaveProperty("provider"); const attempt = await attemptRow(a); expect(attempt.outcome).toBe("completed"); @@ -235,7 +237,7 @@ describe("completeAuthorizationAttempt", () => { account: account(a), action: "Google.SendEmail", scopes: ["gmail.send"], - provider: "google", + authProvider: "google", }); // CONCURRENTLY, on the real pool. A sequential replay passes against an implementation with no @@ -266,7 +268,7 @@ describe("completeAuthorizationAttempt", () => { account: account(a), action: "Google.SendEmail", scopes: ["gmail.send"], - provider: "google", + authProvider: "google", }), ).rejects.toThrow("injected audit failure"); @@ -292,7 +294,7 @@ describe("completeAuthorizationAttempt", () => { account: account(a), action: "Google.SendEmail", scopes: ["gmail.send"], - provider: "google", + authProvider: "google", }); expect((await connectionsFor(app)).length).toBe(0); diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index cd88b278f..e202e7731 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -756,7 +756,7 @@ const connectApp = new Hono().route( encryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", personHasAccess: async () => true, }, - { broker }, + { broker, provider: "composio" }, ), ); diff --git a/server/tests/shared-brokered-routes.test.ts b/server/tests/shared-brokered-routes.test.ts index aa315e370..02e695c27 100644 --- a/server/tests/shared-brokered-routes.test.ts +++ b/server/tests/shared-brokered-routes.test.ts @@ -116,7 +116,7 @@ function appAs(role: "admin" | "user", id = `${role}-${suite}`) { encryptionKey: `${"A".repeat(43)}=`, personHasAccess: async () => true, }, - { broker }, + { broker, provider: "composio" }, { modes, use: sharedUseStore }, ), ); From ecf1ce0e3ac522b118d2e2a0f58816243b7256ff Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:40:04 +0200 Subject: [PATCH 052/232] feat(arcade): put the active provider on the /api/plugins payload and the page type Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/plugins/queries.ts | 26 +++++ app/tests/plugins-page-provider.test.ts | 25 +++++ server/src/plugins/routes.ts | 18 ++++ server/tests/plugins-page-provider.test.ts | 114 +++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 app/tests/plugins-page-provider.test.ts create mode 100644 server/tests/plugins-page-provider.test.ts diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index 05b215df9..1a318f739 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -183,8 +183,34 @@ export type PluginsPage = { * server. */ composioConfigured: boolean; + /** + * Which vendor brokers this deployment's apps, as a NAME. Read it through {@link pluginsProvider}. + * + * A screen that FILTERS by brokered-ness needs no provider name: one provider holds rows at a + * time (spec D3), which is why `brokeredAccountsListedOn` is a predicate over `provenance`. A + * screen that NAMES a vendor ("Browse Composio", the `/admin/plugins/composio` route, + * `COMPOSIO_API_KEY`, "Reached through Composio") cannot derive that name from a row, and this + * field is the only thing that answers it. Separate from `composioConfigured`, which asks whether + * the directory can be browsed at all. + * + * A name, never a key and never a capability: nothing about the vendor key travels with it. + * Optional because a payload from an older server does not carry it. + */ + provider?: "composio" | "arcade"; }; +/** + * The provider a payload names, `"composio"` when the server did not say. + * + * So a Composio deployment renders byte-identically and a page served by an older server does not + * blank. Also `"composio"` before the page has loaded. + */ +export function pluginsProvider( + page: PluginsPage | undefined, +): "composio" | "arcade" { + return page?.provider ?? "composio"; +} + /** What one Bot holds, which is all the runtime needs to offer it. */ export type GrantedPlugins = { tools: { diff --git a/app/tests/plugins-page-provider.test.ts b/app/tests/plugins-page-provider.test.ts new file mode 100644 index 000000000..72a4f1c01 --- /dev/null +++ b/app/tests/plugins-page-provider.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { type PluginsPage, pluginsProvider } from "@/lib/plugins/queries"; + +const page = (extra: Partial): PluginsPage => ({ + catalogue: [], + servers: [], + skills: [], + botsMayCallBack: false, + redirectUri: null, + composioConfigured: true, + ...extra, +}); + +test("a payload with no provider answers composio", () => { + expect(pluginsProvider(page({}))).toBe("composio"); +}); + +test("a payload that names a provider is believed", () => { + expect(pluginsProvider(page({ provider: "arcade" }))).toBe("arcade"); + expect(pluginsProvider(page({ provider: "composio" }))).toBe("composio"); +}); + +test("no page yet answers composio", () => { + expect(pluginsProvider(undefined)).toBe("composio"); +}); diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index a8d1ffaea..6d8074491 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -278,6 +278,24 @@ export function createPluginRoutes( * screen needs is whether to draw the app directory, which is a yes or a no. */ composioConfigured: Boolean(composio), + /* + * WHICH vendor brokers this deployment's apps, as a name. + * + * Handed in with the broker, never read off it: the broker declares what it can do, not who + * it is, and a name sniffed from it would be the comparison the store argues against. + * + * A screen that FILTERS by brokered-ness needs no provider name. One provider holds rows at a + * time, so a predicate over a row's provenance is enough. A screen that NAMES a vendor + * ("Browse Composio", the `/admin/plugins/composio` route, the key's setting, "Reached + * through Composio") cannot derive that name from a row, and this is the only thing that + * answers it. It is separate from `composioConfigured`, which asks whether the directory can + * be browsed at all. + * + * A NAME, never a key and never a capability. The Plugins page is reachable by any signed-in + * person, so nothing about the vendor key (its presence, shape or anything derived from it) + * travels with this. Composio when no broker is configured, so that deployment is unchanged. + */ + provider: composio?.provider ?? "composio", servers: await store.listServers(), // Scoped: the deployment's skills plus this person's own. An administrator sees them all. skills: await store.listSkills(skillActor(context)), diff --git a/server/tests/plugins-page-provider.test.ts b/server/tests/plugins-page-provider.test.ts new file mode 100644 index 000000000..9e9bcac95 --- /dev/null +++ b/server/tests/plugins-page-provider.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { DEV_ACTOR } from "../src/auth/dev-actor"; +import { loadConfig } from "../src/config"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { PluginStore } from "../src/plugins/store"; +import { testEnvironment } from "./support/environment"; + +/** A stand-in for the deployment's vendor key. It must never appear in the page payload. */ +const VENDOR_KEY = "vendor-key-sentinel-0123456789"; + +/** `createApp`'s positions 4-14, which this surface does not supply. */ +const UP_TO_PLUGIN_STORE = [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, +] as const; + +/** `createApp`'s positions 16-29, between the plugin store and the broker. */ +const UP_TO_BROKER = [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, +] as const; + +async function pageFor(composio?: { + broker: unknown; + provider: BrokerProvider; +}) { + const store = { + listServers: async () => [], + listSkills: async () => [], + } as unknown as PluginStore; + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: DEV_ACTOR }) }, + } as never, + { rolesForUser: async () => [] }, + ...UP_TO_PLUGIN_STORE, + store, + ...UP_TO_BROKER, + composio as never, + ); + const response = await app.request("http://openbot.test/api/plugins"); + expect(response.status).toBe(200); + return (await response.json()) as Record; +} + +describe("the Plugins page names the active provider", () => { + test("an Arcade deployment serves arcade", async () => { + const page = await pageFor({ + broker: { key: VENDOR_KEY }, + provider: "arcade", + }); + expect(page.provider).toBe("arcade"); + }); + + test("a Composio deployment serves composio", async () => { + const page = await pageFor({ + broker: { key: VENDOR_KEY }, + provider: "composio", + }); + expect(page.provider).toBe("composio"); + expect(page.composioConfigured).toBe(true); + }); + + test("a deployment with no broker still serves composio", async () => { + const page = await pageFor(undefined); + expect(page.provider).toBe("composio"); + expect(page.composioConfigured).toBe(false); + }); + + test("the name travels, the key and anything derived from it do not", async () => { + const page = await pageFor({ + broker: { key: VENDOR_KEY, grantIsLocal: true }, + provider: "arcade", + }); + expect(Object.keys(page).sort()).toEqual( + [ + "botsMayCallBack", + "catalogue", + "composioConfigured", + "provider", + "redirectUri", + "servers", + "skills", + ].sort(), + ); + const body = JSON.stringify(page); + expect(body).not.toContain(VENDOR_KEY); + expect(body).not.toContain("grantIsLocal"); + }); +}); From 9221642f2e2c3d209bb233fb34fc59b5761e6326 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 13:53:15 +0200 Subject: [PATCH 053/232] fix(arcade): parse brokered urls with the active provider's parser in routes.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `routes.ts` imported `toolkitOf` from `./composio` and read which app a brokered row is with Composio's own parser at four sites. That parser answers null for `arcade://Gmail` by design — the two are deliberately not interchangeable, which is `TOOLKIT_OF`'s whole argument — so on an Arcade deployment one wrong parser produced four different wrong answers, none of which threw and none of which logged: - the account-mode switch refused a brokered app with 400 "That app is not reached through a broker"; - the app directory reported every enabled app as not enabled, and offered to enable what was already there; - `POST /servers/:id/connect` fell past its brokered branch in silence, into the OAuth flow that row never enters; - `brokeredAccountFor` refused before any account was resolved, which is step one of confirm, re-check and disconnect — so authorize, grants and disconnect all refused a perfectly connected app. All four now go through one closure, `appOfUrl`, which dispatches on the provider micro-task 7.0a brought into this layer. The provider is handed in with the broker and never sniffed off it: the broker declares what it can do, not who it is. The page payload's `??` moves up one line into `activeProvider` rather than being spelled a second time, so the name the Plugins page shows and the parser these routes use cannot come apart — and a deployment with no broker keeps answering exactly as it did, which `shared-wiring.integration.test.ts` is what pins. The guard's fourth pattern family is armed with it: `/\btoolkitOf\s*\(/` now names any plugin module outside the three vocabulary modules that reaches past the dispatch for one vendor's parser. It is green with no fourth exemption, which is what micro-task 4.6 escalated for rather than adding; the docblock's unarmed-pattern note is replaced by what the armed pattern guarantees. No route's contract, status code or response shape changes. The Composio deployment's four answers are pinned byte for byte in the new test, and every case is asserted on both providers from one harness, with no Arcade case normalized anywhere. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 72 ++++-- server/tests/brokered-provenance.test.ts | 38 ++-- server/tests/routes-arcade-urls.test.ts | 275 +++++++++++++++++++++++ 3 files changed, 354 insertions(+), 31 deletions(-) create mode 100644 server/tests/routes-arcade-urls.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 6d8074491..a4c755720 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -6,7 +6,7 @@ import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; import { reasonWithoutStatement } from "../db/query-failure"; import type { AccountModeSwitch } from "./account-mode"; -import type { BrokerProvider } from "./access"; +import { type BrokerProvider, toolkitOfFor } from "./access"; import { type BrokerApp, type BrokerField, @@ -20,7 +20,7 @@ import { schemeKind, } from "./broker"; import { CATALOGUE, catalogueEntry } from "./catalogue"; -import { toolkitOf, vendorSentence } from "./composio"; +import { vendorSentence } from "./composio"; import { authorizationUrlFor, type ConnectOrigin, @@ -214,6 +214,49 @@ export function createPluginRoutes( ) { const routes = new Hono<{ Variables: AppVariables }>(); + /** + * WHICH broker this deployment's rows are reached through, as a name, read once for the whole + * layer. + * + * HANDED IN WITH THE BROKER AND NEVER SNIFFED OFF IT. The broker declares what it can DO — + * `grantIsLocal`, `authorizations` — deliberately not who it is, so a vendor name derived from it + * would be the comparison `store.ts` argues against, made against a capability that two providers + * are free to share. + * + * THE `??` IS NOT A SECOND FALLBACK: IT IS THE PAGE PAYLOAD'S, MOVED UP ONE LINE SO THERE IS ONE. + * The payload below already answered `composio` for a deployment with no broker, and the four + * brokered reads below have always parsed with Composio's own parser whether or not a key was + * set. Spelling it in both places is how two readings of "which provider" come apart; spelled + * here, the name the page shows and the parser the routes use cannot disagree. + * + * AND NO BROKER IS NOT THE CASE THIS DECIDES. A deployment with no key reaches no broker, so no + * row of its own can be brokered — but rows outlive a key being unset, and the Personal ⇄ Shared + * switch is wired from the shared bag rather than from the broker, so it answers for such a row + * today. Reading `null` for it instead would turn that 200 into a 400 in the name of a provider + * question nobody asked. The deployment boundary is where "which provider" genuinely has no + * answer, and `server/src/index.ts` is where the system says so. + */ + const activeProvider: BrokerProvider = composio?.provider ?? "composio"; + + /** + * Which app a brokered row is, read by the parser belonging to the broker this deployment runs. + * + * ONE PARSER PER PROVIDER, AND THEY ARE NOT INTERCHANGEABLE — the argument is {@link toolkitOfFor}'s + * and is not repeated here. What this layer adds is that all four reads below used to call + * Composio's parser by name, and that parser answers null for `arcade://Gmail` by design. Four + * reads, four different wrong answers on an Arcade deployment, and not one of them a failure or a + * log line: the account-mode switch refused a brokered app as "not reached through a broker", the + * directory reported every enabled app as not enabled, the connect route fell past its brokered + * branch in silence, and `brokeredAccountFor` — step one of confirm, re-check and disconnect — + * refused a connected app before any account was resolved. + * + * A CLOSURE RATHER THAN THE DISPATCH SPELLED FOUR TIMES, which is how `shared-use-store.ts` holds + * the same thing: the provider is read in one place, so a fifth read added below cannot be the one + * that forgets to pass it. + */ + const appOfUrl = (url: string): string | null => + toolkitOfFor(activeProvider, url); + const actorEmail = (context: { var: AppVariables }) => context.var.actor?.email ?? "unknown"; @@ -293,9 +336,11 @@ export function createPluginRoutes( * * A NAME, never a key and never a capability. The Plugins page is reachable by any signed-in * person, so nothing about the vendor key (its presence, shape or anything derived from it) - * travels with this. Composio when no broker is configured, so that deployment is unchanged. + * travels with this. Composio when no broker is configured, so that deployment is unchanged — + * read from {@link activeProvider}, which is also what the brokered reads below parse with, so + * the name this page shows and the parser those routes use cannot come apart. */ - provider: composio?.provider ?? "composio", + provider: activeProvider, servers: await store.listServers(), // Scoped: the deployment's skills plus this person's own. An administrator sees them all. skills: await store.listSkills(skillActor(context)), @@ -633,7 +678,7 @@ export function createPluginRoutes( return context.json({ error: "Choose personal or shared." }, 400); const serverId = context.req.param("id"); const row = await store.serverAddress(serverId); - if (!row || !toolkitOf(row.url)) { + if (!row || !appOfUrl(row.url)) { return context.json( { error: "That app is not reached through a broker." }, 400, @@ -754,8 +799,9 @@ export function createPluginRoutes( * {@link BrokerUnconfiguredError}'s own message is what is sent rather than a sentence written * here. * - * `enabled` comes off `toolkitOf(url)` and never off the row's id. The url is where the - * transport reads which app a call is against, so it is the only reading that decides anything; + * `enabled` comes off `appOfUrl(url)` — the active broker's own parser — and never off the + * row's id. The url is where the transport reads which app a call is against, so it is the only + * reading that decides anything; * the id names the row — `composio-linear` — and reading one as the other would quietly work * until somebody renamed a row. `serverUrls` hands over the urls and nothing else, which is that * property made structural: there is no id here to read by mistake. @@ -831,7 +877,7 @@ export function createPluginRoutes( const enabled = new Set( urls - .map((url) => toolkitOf(url)) + .map((url) => appOfUrl(url)) .filter((toolkit): toolkit is string => toolkit !== null), ); return context.json({ @@ -1148,7 +1194,7 @@ export function createPluginRoutes( * person. On a deployment with no `OPENBOT_PUBLIC_URL` it failed one step earlier still, * refusing for want of a setting that has no bearing on a flow it does not enter. * - * The app comes off the row's url via `toolkitOf` rather than off its id, for the reason the + * The app comes off the row's url via `appOfUrl` rather than off its id, for the reason the * directory route says: the url is where the transport reads which app a call is against, and * the id is a row name that happens to look similar. * @@ -1159,7 +1205,7 @@ export function createPluginRoutes( * a missing row did when this was a `.find`. */ const row = await store.serverAddress(serverId); - const toolkit = row ? toolkitOf(row.url) : null; + const toolkit = row ? appOfUrl(row.url) : null; if (row && toolkit) { /* * AN APP THAT NEEDS NO CREDENTIAL IS ANSWERED HERE, BEFORE `brokeredAccountFor` IS EVER @@ -1864,7 +1910,7 @@ export function createPluginRoutes( * * A ROW THAT IS NOT BROKERED IS REFUSED IN SO MANY WORDS. The id may well name a server this * deployment really has — what is wrong is that its connection does not live at Composio, and - * there is nothing for either route to confirm or to end. `null` from `toolkitOf` also covers an + * there is nothing for either route to confirm or to end. `null` from `appOfUrl` also covers an * id naming no row at all, which is the same answer from the caller's side. * * NO BROKER IS A 503 NAMING THE SETTING, as it is on the directory and on connect, and it is @@ -1877,7 +1923,7 @@ export function createPluginRoutes( * actor who is not an administrator is refused here, once, instead of in four routes below. * * THE CONNECT ROUTE'S BROKERED BRANCH IS THE FOURTH, CALLED ONLY ONCE IT ALREADY KNOWS THE - * BRANCH IS TAKEN. That route still asks `toolkitOf` itself before this function is ever reached + * BRANCH IS TAKEN. That route still asks `appOfUrl` itself before this function is ever reached * — a row that is not brokered has an OAuth flow below it to fall through to, and refusing it * here, where there is no such flow to fall through to, would be wrong. Once that question is * settled and the answer is yes, whose account the connection is for is exactly the question @@ -1902,7 +1948,7 @@ export function createPluginRoutes( } > => { const row = await store.serverAddress(serverId); - const toolkit = row ? toolkitOf(row.url) : null; + const toolkit = row ? appOfUrl(row.url) : null; if (!toolkit) { return { refusal: { diff --git a/server/tests/brokered-provenance.test.ts b/server/tests/brokered-provenance.test.ts index 804920ce6..d65685ea9 100644 --- a/server/tests/brokered-provenance.test.ts +++ b/server/tests/brokered-provenance.test.ts @@ -24,25 +24,21 @@ * The offenders are collected into a list and compared against `[]` rather than asserted one file at * a time, so a new site has to be argued for here rather than quietly allowed. * - * THE OTHER HALF OF THIS GUARD IS NOT ARMED YET, and this is the record of why. A direct call to a - * vendor module's own parser — `toolkitOf(url)` rather than `toolkitOfFor(provider, url)` — is the - * same mistake as a spelled scheme, and the pattern for it is `/\btoolkitOf\s*\(/`. Run today it - * names eight lines: + * THE FOURTH PATTERN FAMILY IS THE PARSER CALL, AND IT IS ARMED. A direct call to a vendor module's + * own parser — `toolkitOf(url)` rather than `toolkitOfFor(provider, url)` — is the same mistake as a + * spelled scheme, reached one step further in: it does not name a vendor, it IS one, and it answers + * null for every url the other broker mints rather than failing. What that bought on an Arcade + * deployment was four different wrong answers from one wrong parser, none of which read as a parser + * problem — an app refused as "not reached through a broker", a directory offering to enable what + * was already enabled, a connect route falling past its own brokered branch, and an authorize, + * grants and disconnect that all refused a connected app. * - * `routes.ts` 617, 815, 1143, 1886 — genuine. That module imports `toolkitOf` from `./composio` - * and parses every brokered url with Composio's parser, so on any other broker each of these - * answers null and the route refuses an app that is perfectly well connected. It cannot be fixed - * here: `routes.ts` holds no provider at all (`grep -n 'provider' server/src/plugins/routes.ts` - * is empty), so routing these through `toolkitOfFor` means wiring the active broker into the - * routes layer, which is a change of its own. - * - * `shared-use-store.ts` 154, 427, 484, 492 — false positives. Those call a LOCAL closure that - * happens to be named `toolkitOf` and whose body is `toolkitOfFor(provider, url)`: the dispatch - * itself, read by the pattern as the thing it dispatches to. - * - * Arming it now would mean exempting a fourth and fifth module, which is exactly the hatch the - * paragraph above refuses. So it waits for the routes layer to learn its provider and for that - * closure to be renamed, and this comment is what stops the pattern being quietly dropped instead. + * WHAT THE ARMED PATTERN NOW GUARANTEES: outside the three vocabulary modules, no plugin module + * calls `toolkitOf` at all. Every brokered url in `src/plugins` is parsed through + * `toolkitOfFor(provider, url)`, with the provider handed in rather than sniffed — so adding a + * broker changes the dispatch table in `access.ts` and nothing else, and a module that reaches past + * the dispatch for one vendor's parser is named here before it ships. The only lines the pattern + * still matches are the definitions themselves, in the three exempt modules it does not scan. */ import { expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; @@ -65,6 +61,10 @@ const PATTERNS: RegExp[] = [ // is what dropped every Arcade connection off the settings page and what sent an Arcade // deployment's probe to Composio; the scheme comes out of `BROKERED_PROFILE` now. /["'`](composio|arcade):\/\//, + // A vendor module's own parser called directly, not the dispatch. `toolkitOf(url)` reads one + // broker's scheme and answers null for the other's, so a module holding the active provider must + // ask `toolkitOfFor(provider, url)` instead. + /\btoolkitOf\s*\(/, ]; /** A line of prose rather than a line of code. See the docblock: the skip is narrow on purpose. */ @@ -107,12 +107,14 @@ test("the comment skip takes prose and leaves every expression", () => { ' return { url: "composio://gmail" };', '\tif (row.provenance === "arcade") return null;', 'const u = "composio://" + toolkit; /* trailing block */', + " const toolkit = row ? toolkitOf(row.url) : null;", ]; const prose = [ " * a row called `gmail` at `composio://slack` would decide a Slack confirm", "// `arcade://Gmail` and `arcade://gmail` are distinct apps.", "/* `composio://` is the scheme the rows are minted at. */", ' * `provenance === "composio"` was the comparison this replaced.', + " * `enabled` comes off `toolkitOf(url)` and never off the row's id.", ]; for (const line of code) { expect(isComment(line)).toBe(false); diff --git a/server/tests/routes-arcade-urls.test.ts b/server/tests/routes-arcade-urls.test.ts new file mode 100644 index 000000000..fceb0bc03 --- /dev/null +++ b/server/tests/routes-arcade-urls.test.ts @@ -0,0 +1,275 @@ +// server/tests/routes-arcade-urls.test.ts +/** + * The four places `routes.ts` reads which app a brokered row is, asked on BOTH brokers. + * + * Every one of them parsed the url with Composio's own `toolkitOf`, which answers null for + * `arcade://Gmail` by design — the two parsers are deliberately not interchangeable, which is the + * whole argument of `TOOLKIT_OF` in `access.ts`. So on an Arcade deployment the account-mode switch + * refused a brokered app as "not reached through a broker", the directory reported every enabled + * app as not enabled, the connect route fell past its brokered branch in silence, and + * `brokeredAccountFor` — step one of authorize, grants and disconnect — refused a perfectly + * connected app before any account was resolved. + * + * EACH CASE IS ASSERTED ON BOTH PROVIDERS, from one harness, and the Composio half is the evidence + * that nothing about that deployment moved: the same route, the same status, the same body, the + * same toolkit reaching the store. The Arcade half is what was red. + * + * ARCADE CASE IS NOT NORMALIZED ANYWHERE HERE. The row is `arcade://Gmail` and what every + * assertion expects back is `Gmail`, because `arcade://Gmail` and `arcade://gmail` are distinct + * apps. + */ +import { describe, expect, test } from "bun:test"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { AccountModeSwitch } from "../src/plugins/account-mode"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { BrokerApp, ComposioBroker } from "../src/plugins/broker"; +import { createPluginRoutes } from "../src/plugins/routes"; +import type { AccountRef } from "../src/plugins/shared-accounts"; +import type { SharedUseStore } from "../src/plugins/shared-use-store"; +import type { PluginStore } from "../src/plugins/store"; + +/** The row each provider holds for the same app, spelled the way that provider spells it. */ +const ROW: Record = { + composio: { url: "composio://gmail", toolkit: "gmail" }, + arcade: { url: "arcade://Gmail", toolkit: "Gmail" }, +}; + +const SERVER_ID = "brokered-gmail"; + +/** One directory entry, named as the provider under test names the app. */ +function directoryApp(slug: string): BrokerApp { + return { + slug, + name: "Gmail", + description: "Mail", + logo: null, + categories: [], + actionCount: 3, + connection: { kind: "consent" }, + }; +} + +/** + * The plugins surface wired to one provider, with every toolkit that reached the broker or the + * store recorded. + * + * Stubs rather than a database: what is under test is which parser read the row's url, and that is + * decided in the route before any of these are called. + */ +function deployment(provider: BrokerProvider) { + const row = ROW[provider]; + const confirmed: Array<{ toolkit: string; account: AccountRef }> = []; + const authorized: Array<{ toolkit: string }> = []; + const switched: Array<{ serverId: string; mode: string }> = []; + + const store = { + serverAddress: async (serverId: string) => + serverId === SERVER_ID + ? { + id: SERVER_ID, + title: "Gmail", + url: row.url, + authScheme: "OAUTH2", + } + : undefined, + serverUrls: async () => [row.url], + accountRefFor: async (_serverId: string, actorId: string) => ({ + ref: { + holder: "person" as const, + userId: actorId, + vendorUserId: actorId, + }, + title: "Gmail", + mode: "personal" as const, + }), + brokeredConnection: async () => null, + confirmBrokeredConnection: async (input: { + toolkit: string; + account: AccountRef; + }) => { + confirmed.push({ toolkit: input.toolkit, account: input.account }); + return { connected: true }; + }, + listSkills: async () => [], + } as unknown as PluginStore; + + const broker = { + grantIsLocal: true, + authorizations: null, + listApps: async () => [directoryApp(row.toolkit)], + authorize: async (input: { toolkit: string }) => { + authorized.push({ toolkit: input.toolkit }); + return { redirectUrl: "https://vendor.example/consent" }; + }, + } as unknown as ComposioBroker; + + const modes = { + switchMode: async (input: { serverId: string; mode: string }) => { + switched.push({ serverId: input.serverId, mode: input.mode }); + return { ended: [], approved: [] }; + }, + forgetRule: async () => {}, + } as unknown as AccountModeSwitch; + + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: "admin-1", + email: "admin@example.test", + role: "admin", + } as never); + await next(); + }; + + const app = new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + async () => true, + { + publicUrl: "https://openbot.example", + appUrl: "https://app.example", + encryptionKey: `${"A".repeat(43)}=`, + personHasAccess: async () => true, + }, + { broker, provider }, + { modes, use: {} as unknown as SharedUseStore }, + ), + ); + + return { + confirmed, + authorized, + switched, + toolkit: row.toolkit, + confirm: () => + app.request( + `http://openbot.test/api/plugins/servers/${SERVER_ID}/connection/confirm`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }, + ), + accountMode: () => + app.request( + `http://openbot.test/api/plugins/servers/${SERVER_ID}/account-mode`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "shared" }), + }, + ), + directory: () => + app.request("http://openbot.test/api/plugins/composio/apps"), + connect: () => + app.request( + `http://openbot.test/api/plugins/servers/${SERVER_ID}/connect`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }, + ), + }; +} + +const PROVIDERS: BrokerProvider[] = ["arcade", "composio"]; + +for (const provider of PROVIDERS) { + describe(`a ${provider} deployment reads its own urls`, () => { + test("brokeredAccountFor resolves an account rather than refusing", async () => { + const it = deployment(provider); + const response = await it.confirm(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ connected: true }); + expect(it.confirmed).toEqual([ + { + toolkit: it.toolkit, + account: { + holder: "person", + userId: "admin-1", + vendorUserId: "admin-1", + }, + }, + ]); + }); + + test("the account-mode switch accepts the row", async () => { + const it = deployment(provider); + const response = await it.accountMode(); + expect(response.status).toBe(200); + expect(it.switched).toEqual([{ serverId: SERVER_ID, mode: "shared" }]); + }); + + test("the directory reports the app as enabled", async () => { + const it = deployment(provider); + const response = await it.directory(); + expect(response.status).toBe(200); + const body = (await response.json()) as { + apps: Array<{ slug: string; enabled: boolean }>; + }; + expect(body.apps.map((app) => [app.slug, app.enabled])).toEqual([ + [it.toolkit, true], + ]); + }); + + test("the connect route takes the brokered branch", async () => { + const it = deployment(provider); + const response = await it.connect(); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + authorizationUrl: "https://vendor.example/consent", + }); + expect(it.authorized).toEqual([{ toolkit: it.toolkit }]); + }); + }); +} + +/** + * And the Composio deployment's four answers are byte for byte what they were before the parse was + * dispatched, which is what makes this a replacement of a parser rather than a change of behaviour. + * Pinned as raw response text, so a reordered key or a reworded sentence fails here rather than + * being noticed by inspection. + */ +describe("a Composio deployment answers byte for byte what it did", () => { + const EXPECTED: Record< + "confirm" | "accountMode" | "directory" | "connect", + string + > = { + confirm: '{"connected":true}', + accountMode: '{"ended":[],"approved":[]}', + directory: + '{"apps":[{"slug":"gmail","name":"Gmail","description":"Mail","logo":null,"categories":[],"actionCount":3,"connection":{"kind":"consent"},"enabled":true}]}', + connect: '{"authorizationUrl":"https://vendor.example/consent"}', + }; + + for (const route of Object.keys(EXPECTED) as Array) { + test(`${route} is unchanged`, async () => { + const response = await deployment("composio")[route](); + expect(response.status).toBe(200); + expect(await response.text()).toBe(EXPECTED[route]); + }); + } + + /** + * And the Arcade deployment's answers are the same text, the app's own name apart — which is the + * one thing that differs between two deployments holding the same app at two brokers. + */ + test("and Arcade answers the same, the app's name apart", async () => { + for (const route of ["confirm", "accountMode", "connect"] as const) { + const response = await deployment("arcade")[route](); + expect(response.status).toBe(200); + expect(await response.text()).toBe(EXPECTED[route]); + } + const directory = await deployment("arcade").directory(); + expect(directory.status).toBe(200); + expect(await directory.text()).toBe( + EXPECTED.directory.replace('"slug":"gmail"', '"slug":"Gmail"'), + ); + }); +}); From b484939344ea254dc6b46245959dc6637a59b0a6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 14:16:41 +0200 Subject: [PATCH 054/232] feat(arcade): measure the three enablement facts in listApps' existing fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BrokerApp` and `ComposioApp` gain `classifiedCount`, `permissionCount` and `scopedActionCount`, all REQUIRED AND NULLABLE. `0` means measured, and nothing was classified, so the coverage warning fires; `null` means not measured, so the page says nothing. Those are opposite sentences, and an optional field makes them the same shape at the call site where the natural `?? 0` collapses the second into the first — firing a coverage warning on a Composio deployment, a false alarm at exactly the moment somebody is deciding whether to trust an app. So `composio-adapter.ts` writes three explicit nulls and nothing writes `?? 0`. Arcade measures them INSIDE THE LISTING IT ALREADY PAGES TO THE END for `actionCount`, before the tools are discarded: no new vendor call and no second listing. `classifiedCount` reads `metadata.behavior` through `classify()`, never the `Api` suffix (spec D8) — the suffix is Arcade's naming convention and not a contract, so a rule reading it is a guess wearing a measurement's clothes right until Arcade curates `DatadogApi`. `permissionCount` counts distinct non-empty scope sets, sorted before joined so `[a,b]` and `[b,a]` are one permission. An action with no scopes is not its own permission, which was measured rather than chosen: Github's 43 actions all carry an `oauth2` block with no scopes and one read consent flipped 43 of 43 to `met: true`. `scopedActionCount` exists because without it a mixed toolkit reports `permissionCount: 1` across 2 actions, which reads as one permission covering both while the second is covered by any approval at all. The fixture is read and NOT appended to — its value is that it is what Arcade actually returned. `permissionCount > 1` is a pure counting property needing no realism, so that case is constructed inline, sorted-order proof included. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/plugins/queries.ts | 17 ++ app/tests/composio-picker.test.tsx | 6 + server/src/plugins/arcade-adapter.ts | 121 +++++++++++-- server/src/plugins/broker.ts | 37 ++++ server/src/plugins/composio-adapter.ts | 10 ++ server/tests/arcade-enablement-facts.test.ts | 171 +++++++++++++++++++ server/tests/composio-adapter.test.ts | 25 +++ 7 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 server/tests/arcade-enablement-facts.test.ts diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index 1a318f739..6de6e061f 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -151,6 +151,23 @@ export type ComposioApp = { categories: string[]; /** The size of the decision, shown before enabling. */ actionCount: number; + /** + * How many of those actions the provider classified, how many distinct non-empty scope sets they + * ask for, and how many carry a scope at all — three more facts of the size-of-the-decision kind. + * + * REQUIRED AND NULLABLE, NEVER OPTIONAL, AND NOTHING HERE WRITES `?? 0`. `0` means measured and + * none, so the coverage warning fires; `null` means the provider does not publish it, so the page + * says nothing. An optional field makes those the same shape at the call site, and the natural + * `?? 0` fires a coverage warning on a Composio deployment — a false alarm at exactly the moment + * somebody is deciding whether to trust an app. + * + * An action with no scopes is not its own permission: it is covered by any approval at all, so it + * joins no group. `scopedActionCount` is what keeps `permissionCount: 1` over two actions from + * reading as one permission covering both. + */ + classifiedCount: number | null; + permissionCount: number | null; + scopedActionCount: number | null; enabled: boolean; }; diff --git a/app/tests/composio-picker.test.tsx b/app/tests/composio-picker.test.tsx index d61747b05..05a8f97c8 100644 --- a/app/tests/composio-picker.test.tsx +++ b/app/tests/composio-picker.test.tsx @@ -24,6 +24,12 @@ function app(overrides: Partial & { slug: string }): ComposioApp { logo: null, categories: [], actionCount: 1, + // Required and nullable, never optional: a null is "not measured", which is a different fact + // from a zero's "measured, and none". A default here would have to pick one, so it picks the + // one a provider that publishes nothing actually answers with. + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, enabled: false, ...overrides, }; diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 44908e29e..259839c8c 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1,7 +1,8 @@ import Arcade, { APIError } from "@arcadeai/arcadejs"; -import type { ArcadeActions, ArcadeFailure } from "./arcade"; +import { classify, type ArcadeActions, type ArcadeFailure } from "./arcade"; import { BrokerRefusalError, + flagOf, type BrokerApp, type ConnectedAppBroker, type GrantState, @@ -59,15 +60,85 @@ const CATALOGUE_PAGE_SIZE = 100; /** A runaway guard, not a bound. */ const CATALOGUE_MAX_PAGES = 1000; +/** The verdict `classify` falls back to when it could not read a behaviour. See {@link isClassified}. */ +const CLOSED_VERDICT = { effect: "write", destructive: true } as const; + +/** + * Whether ARCADE classified this action, as against this deployment falling back for it. + * + * `classify` from `./arcade` is the one reading of `metadata.behavior` anywhere here, and it is + * deliberately TOTAL: anything it cannot read answers the closed `{ write, destructive }` verdict, + * so an unannotated action is approved like a deletion. That fallback is exactly what must not be + * counted — counted, `AirtableApi`'s `metadata: null` actions would report as classified, and the + * coverage warning that exists to name them would never fire. + * + * THE CLOSED VERDICT IS ALSO A REAL ANSWER, which is the only subtlety here: `Attio.RemoveFromList` + * genuinely publishes `destructive: true` and lands on the same pair. So the verdict is read first, + * and only that one collision is broken — by asking whether Arcade actually sent a `behavior` whose + * `destructive` flag reads true, through `flagOf`, the same function `classify` reads it with and + * never a second parse of the wire. + * + * NEVER THE `Api` SUFFIX (spec D8). `AirtableApi` and `GithubApi` are unannotated today and + * `Apollo` is annotated, so the suffix correlates beautifully — and it is Arcade's naming + * convention rather than a contract. A rule reading it is a guess wearing a measurement's clothes, + * right until Arcade curates `DatadogApi`. + */ +function isClassified(metadata: unknown): boolean { + const verdict = classify(metadata); + if ( + verdict.effect !== CLOSED_VERDICT.effect || + verdict.destructive !== CLOSED_VERDICT.destructive + ) { + return true; + } + const behavior = record(record(metadata)?.behavior); + return behavior !== null && flagOf(behavior.destructive, false) === true; +} + +/** The oauth2 scopes one listed action asks for, by the path `grantStateOf` reads them on. */ +function scopesOf(item: unknown): string[] { + const authorization = record(record(item)?.requirements)?.authorization; + const scopes = record(authorization)?.oauth2; + const listed = record(scopes)?.scopes; + return Array.isArray(listed) + ? listed.filter((s): s is string => typeof s === "string") + : []; +} + /** * Every toolkit Arcade publishes, with `actionCount` taken from the FULL PAGED tool listing. * * COUNTED, NEVER READ OFF PAGE ONE: `GithubApi` publishes hundreds of actions where `Github` * publishes 43, so a first page of 100 would be a wrong answer in the picker. Pagination ends on a * short page; `total_count` is optional on the wire and is used only as a cross-check. + * + * THE OTHER THREE FACTS ARE COUNTED IN THIS SAME FOLD, BEFORE THE TOOLS ARE DISCARDED, and that is + * the whole of why they are free: the listing is already paged to the end for `actionCount`, so no + * second listing and NO EXTRA VENDOR CALL is made for them. + * + * `permissionCount` is the number of DISTINCT NON-EMPTY scope sets, held per toolkit as sorted, + * joined strings — SORTED BEFORE JOINED, so `[a,b]` and `[b,a]` are one permission asked for twice. + * An action with NO scopes joins no group: Github's 43 actions all carry an `oauth2` block with no + * scopes and one read consent flipped 43 of 43 to `met: true`, so "no scopes" means "the connection + * itself is the grant", not "needs nothing" — such an action is not separately approvable. + * `scopedActionCount` is what keeps that from reading as one permission covering everything. */ async function listApps(client: Arcade): Promise { - const counts = new Map(); + /** + * The fold's own tally, with PLAIN NUMBERS. `BrokerApp`'s three counts are nullable because + * another provider may not measure them; this provider does, on every action it folds, so a + * nullable accumulator here would only be a null nothing can produce, narrowed at every `+= 1`. + * `permissions` holds the sorted-and-joined scope sets; empty sets never enter it. + */ + type Tally = Omit< + BrokerApp, + "classifiedCount" | "permissionCount" | "scopedActionCount" + > & { + classifiedCount: number; + scopedActionCount: number; + permissions: Set; + }; + const counts = new Map(); let seen = 0; let total: number | undefined; let ended = false; @@ -87,21 +158,32 @@ async function listApps(client: Arcade): Promise { item as { toolkit?: { name?: unknown; description?: unknown } } | null )?.toolkit; if (!toolkit || typeof toolkit.name !== "string") continue; - const standing = counts.get(toolkit.name); - if (standing) { - standing.actionCount += 1; - continue; + const classified = isClassified(record(item)?.metadata); + const scopes = scopesOf(item); + const standing = + counts.get(toolkit.name) ?? + ({ + slug: toolkit.name, + name: toolkit.name, + description: + typeof toolkit.description === "string" ? toolkit.description : "", + logo: null, + categories: [], + actionCount: 0, + classifiedCount: 0, + scopedActionCount: 0, + permissions: new Set(), + connection: { kind: "consent" }, + } satisfies Tally); + standing.actionCount += 1; + if (classified) standing.classifiedCount += 1; + if (scopes.length > 0) { + standing.scopedActionCount += 1; + // SORTED, THEN JOINED. The other order makes one permission into two the first time the + // vendor lists the same scopes in a different sequence. + standing.permissions.add([...scopes].sort().join(" ")); } - counts.set(toolkit.name, { - slug: toolkit.name, - name: toolkit.name, - description: - typeof toolkit.description === "string" ? toolkit.description : "", - logo: null, - categories: [], - actionCount: 1, - connection: { kind: "consent" }, - }); + counts.set(toolkit.name, standing); } seen += items.length; const reported = (answer as { total_count?: unknown }).total_count; @@ -121,7 +203,12 @@ async function listApps(client: Arcade): Promise { `Arcade listed ${seen} of ${total} actions, so its app catalogue is incomplete.`, ); } - return [...counts.values()]; + // `permissionCount` IS READ OFF THE FINISHED SET, never kept per page: a size taken mid-listing + // is a count of the sets seen so far, which is a different number on every page but the last. + return [...counts.values()].map(({ permissions, ...app }) => ({ + ...app, + permissionCount: permissions.size, + })); } const FIELDS_REFUSAL = diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index c4826f60b..4172b0c54 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -318,6 +318,43 @@ export type BrokerApp = { * made the choice this field exists to inform. */ actionCount: number; + /** + * How many of those actions the provider itself classified, or null where nobody measured. + * + * REQUIRED AND NULLABLE, WHICH IS THE WHOLE POINT OF THE FIELD. `0` means MEASURED, AND NOTHING + * WAS CLASSIFIED — every action of this app will be approved like a deletion, so the coverage + * warning fires. `null` means NOT MEASURED — this provider does not publish the fact, so the page + * says nothing. Those are opposite sentences. + * + * An OPTIONAL field makes them the same shape at the call site, and the natural `?? 0` collapses + * "unmeasured" into "measured none" — firing a coverage warning on a Composio deployment, a false + * alarm at exactly the moment somebody is deciding whether to trust an app. So every projection + * states the value, `./composio-adapter` writes an explicit `null`, and NOTHING WRITES `?? 0`. + */ + classifiedCount: number | null; + /** + * How many DISTINCT NON-EMPTY scope sets the app's actions ask for, or null where nobody measured. + * + * The count of things that are separately approvable. Two actions asking for the same scopes are + * one permission; `[a,b]` and `[b,a]` are the same set and so are one too, which is why the sets + * are SORTED before they are joined. + * + * AN ACTION WITH NO SCOPES IS NOT ITS OWN PERMISSION — measured, not chosen. Github's 43 actions + * all carry an `oauth2` block with no scopes, and one read consent flipped 43 of 43 to + * `met: true`. So "no scopes" never meant "needs nothing"; it meant "the connection itself is the + * grant, and any consent covers this". Such an action is not separately approvable, so it joins + * no group and this count does not move for it. See {@link BrokerApp.scopedActionCount}. + */ + permissionCount: number | null; + /** + * How many actions carry AT LEAST ONE scope, or null where nobody measured. + * + * IT EXISTS BECAUSE WITHOUT IT A MIXED APP LIES BY ARITHMETIC. A toolkit of two actions where one + * asks for scopes and one asks for none reports `permissionCount: 1` across `actionCount: 2`, + * which reads as ONE PERMISSION COVERING BOTH — while the second is in fact covered by any + * approval at all. Those are different facts, and this is the field that tells them apart. + */ + scopedActionCount: number | null; /** How somebody would connect to it. See {@link BrokerConnection}. */ connection: BrokerConnection; }; diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 9027b14f4..38ecfe6b4 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -1090,6 +1090,16 @@ function appOf(row: VendorToolkit, position: number): BrokerApp { logo, categories, actionCount, + /* + * THREE EXPLICIT NULLS, AND NEVER `?? 0`. Composio's toolkit listing publishes no behaviour + * classification and no per-action scope sets, so this deployment has not measured them here — + * which is a different fact from having measured them and found none. A zero would say the + * second, and the page would fire its coverage warning on every Composio app: a false alarm at + * exactly the moment somebody is deciding whether to trust one. See {@link BrokerApp}. + */ + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: connectionOf(row), }; } diff --git a/server/tests/arcade-enablement-facts.test.ts b/server/tests/arcade-enablement-facts.test.ts new file mode 100644 index 000000000..75b854d8c --- /dev/null +++ b/server/tests/arcade-enablement-facts.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import Arcade from "@arcadeai/arcadejs"; +import fixture from "./fixtures/arcade-catalogue.json"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { BrokerApp } from "../src/plugins/broker"; + +/** + * The three enablement facts `listApps` measures beside `actionCount`: how many actions Arcade + * classified, how many distinct non-empty scope sets they ask for, and how many carry a scope. + * + * MEASURED INSIDE THE EXISTING FOLD. `listApps` already pages `tools.list` to the end and folds + * every tool into a per-toolkit `actionCount` before discarding the tools; these three are counted + * before that discard. No second listing, no extra vendor call — which `asked` asserts below. + */ +type Methods = Record unknown>; +const toolsProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).tools, + ) as Methods; + +const restores: (() => void)[] = []; +function stub( + on: Methods, + name: string, + impl: (...args: unknown[]) => unknown, +) { + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} +afterEach(() => { + while (restores.length) restores.pop()?.(); +}); + +/** Serves one page of `items` and records every offset asked for. */ +function serving(items: unknown[]): { asked: number[] } { + const asked: number[] = []; + stub(toolsProto(), "list", (...args: unknown[]) => { + const q = args[0] as { offset: number }; + asked.push(q.offset); + return Promise.resolve( + q.offset === 0 + ? { items, total_count: items.length } + : { items: [], total_count: items.length }, + ); + }); + return { asked }; +} + +const listed = async () => + await createArcadeClient("never-dialled").broker.listApps(); + +const appOf = (apps: BrokerApp[], slug: string): BrokerApp => { + const app = apps.find((a) => a.slug === slug); + if (!app) throw new Error(`no app ${slug} in the directory`); + return app; +}; + +/** + * THE FIXTURE IS EVIDENCE ABOUT WHAT ARCADE RETURNS, so it is read and never appended to. Its five + * tools are verbatim live shapes: three classified toolkits, one unclassified one, and inside that + * one the mixed case (one action with a scope, one with an empty `oauth2` block). + */ +const FIXTURE_TOOLS = Object.values( + fixture.tools as Record, +) as unknown[]; + +describe("listApps measures the enablement facts from the fixture", () => { + test("classifiedCount equals actionCount where Arcade published a behavior", async () => { + const { asked } = serving(FIXTURE_TOOLS); + const apps = await listed(); + // No second listing: one short page ended it. + expect(asked).toEqual([0]); + for (const slug of ["Apollo", "Asana", "Attio"]) { + const app = appOf(apps, slug); + expect(app.classifiedCount).toBe(app.actionCount); + } + }); + + test("classifiedCount is 0 for a toolkit Arcade published no behavior for", async () => { + serving(FIXTURE_TOOLS); + const app = appOf(await listed(), "AirtableApi"); + expect(app.actionCount).toBe(2); + // NEVER THE `Api` SUFFIX: the zero comes from `metadata: null` on both of its actions. + expect(app.classifiedCount).toBe(0); + }); + + test("permissionCount is 0 where no action carries an oauth2 block at all", async () => { + serving(FIXTURE_TOOLS); + const app = appOf(await listed(), "Apollo"); + expect(app.permissionCount).toBe(0); + expect(app.scopedActionCount).toBe(0); + }); + + test("permissionCount is 1 for a toolkit whose actions share one scope set", async () => { + serving(FIXTURE_TOOLS); + const apps = await listed(); + for (const slug of ["Asana", "Attio"]) { + const app = appOf(apps, slug); + expect(app.permissionCount).toBe(1); + expect(app.scopedActionCount).toBe(1); + expect(app.actionCount).toBe(1); + } + }); + + /** + * THE MIXED CASE, and the reason `scopedActionCount` exists. `AirtableApi` publishes + * `AddBaseCollaborator` with `workspacesAndBases:write` and `ListBases` with an EMPTY `oauth2` + * block. Without the third number this reads as one permission covering both actions; with it, + * the second is what it is — covered by any approval at all, and not separately approvable. + */ + test("a scopeless action joins no permission group", async () => { + serving(FIXTURE_TOOLS); + const app = appOf(await listed(), "AirtableApi"); + expect(app.actionCount).toBe(2); + expect(app.permissionCount).toBe(1); + expect(app.scopedActionCount).toBe(1); + }); +}); + +/** + * `permissionCount > 1` IS A PURE COUNTING PROPERTY AND NEEDS NO REALISM, so it is constructed + * here rather than appended to the fixture. The fixture's value is that it is what Arcade actually + * returned; a second append makes that claim slightly less true while the file still carries it. + */ +describe("listApps counts distinct scope sets", () => { + const scoped = (name: string, scopes: string[]) => ({ + qualified_name: `Constructed.${name}`, + toolkit: { name: "Constructed", description: "constructed" }, + metadata: { behavior: { read_only: true, destructive: false } }, + requirements: { + authorization: { provider_type: "oauth2", oauth2: { scopes } }, + }, + }); + + test("two distinct scope sets answer permissionCount 2", async () => { + const tools = [scoped("One", ["a", "b"]), scoped("Two", ["c"])]; + serving(tools); + const app = appOf(await listed(), "Constructed"); + expect(app.actionCount).toBe(2); + expect(app.permissionCount).toBe(2); + expect(app.scopedActionCount).toBe(2); + expect(app.classifiedCount).toBe(2); + }); + + /** SORTED BEFORE JOINED: `[a,b]` and `[b,a]` are the same permission, asked for twice. */ + test("the same scopes in a different order are ONE permission", async () => { + const tools = [scoped("One", ["a", "b"]), scoped("Two", ["b", "a"])]; + serving(tools); + const app = appOf(await listed(), "Constructed"); + expect(app.actionCount).toBe(2); + expect(app.permissionCount).toBe(1); + expect(app.scopedActionCount).toBe(2); + }); + + /** Three actions, two sets, one of them reordered: the order never adds a permission. */ + test("sorting does not collapse genuinely different sets", async () => { + const tools = [ + scoped("One", ["a", "b"]), + scoped("Two", ["b", "a"]), + scoped("Three", ["a", "c"]), + ]; + serving(tools); + const app = appOf(await listed(), "Constructed"); + expect(app.actionCount).toBe(3); + expect(app.permissionCount).toBe(2); + expect(app.scopedActionCount).toBe(3); + }); +}); diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 634107897..552f4b5bb 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -487,6 +487,11 @@ describe("the app catalogue", () => { logo: "https://logos.composio.dev/gmail.png", categories: ["Productivity", "Email"], actionCount: 63, + // Composio measures none of the three, so each is an explicit null: "not measured", which is + // a different fact from "measured, and none" and must never be read through a `?? 0`. + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: NO_SCHEME, }, { @@ -496,6 +501,11 @@ describe("the app catalogue", () => { logo: null, categories: [], actionCount: 0, + // Composio measures none of the three, so each is an explicit null: "not measured", which is + // a different fact from "measured, and none" and must never be read through a `?? 0`. + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: NO_SCHEME, }, ]); @@ -531,6 +541,11 @@ describe("holding the catalogue", () => { logo: null, categories: [], actionCount: 63, + // Composio measures none of the three, so each is an explicit null: "not measured", which is + // a different fact from "measured, and none" and must never be read through a `?? 0`. + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: NO_SCHEME, }; @@ -602,6 +617,11 @@ describe("holding the catalogue", () => { logo: null, categories: [], actionCount: 12, + // Composio measures none of the three, so each is an explicit null: "not measured", which is + // a different fact from "measured, and none" and must never be read through a `?? 0`. + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: NO_SCHEME, }, ]); @@ -2872,6 +2892,11 @@ describe("a catalogue that might be a fragment", () => { logo: null, categories: ["Productivity"], actionCount: 63, + // Composio measures none of the three, so each is an explicit null: "not measured", which is + // a different fact from "measured, and none" and must never be read through a `?? 0`. + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: NO_SCHEME, }, ]); From fd33d0a3a59eca2f9ae4508b09c4823798837dfc Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 14:30:23 +0200 Subject: [PATCH 055/232] test(arcade): carry the three enablement facts as explicit null on every typed BrokerApp fixture Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/plugin-logos.integration.test.ts | 3 +++ server/tests/plugin-routes.test.ts | 12 ++++++++++++ server/tests/routes-arcade-urls.test.ts | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/tests/plugin-logos.integration.test.ts b/server/tests/plugin-logos.integration.test.ts index 9d99bb0cd..7625cff3a 100644 --- a/server/tests/plugin-logos.integration.test.ts +++ b/server/tests/plugin-logos.integration.test.ts @@ -43,6 +43,9 @@ function app(slug: string): BrokerApp { logo: "https://example.com/logo.svg", categories: [], actionCount: 0, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: { kind: "fields", authScheme: "API_KEY" }, }; } diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 5222e1ca7..28b94876a 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -748,6 +748,9 @@ const DIRECTORY: BrokerApp[] = [ logo: "https://example.com/slack.svg", categories: ["communication"], actionCount: 63, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: { kind: "consent" }, }, { @@ -757,6 +760,9 @@ const DIRECTORY: BrokerApp[] = [ logo: null, categories: ["communication"], actionCount: 24, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: { kind: "consent" }, }, { @@ -766,6 +772,9 @@ const DIRECTORY: BrokerApp[] = [ logo: null, categories: ["project-management"], actionCount: 18, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: { kind: "consent" }, }, { @@ -781,6 +790,9 @@ const DIRECTORY: BrokerApp[] = [ logo: null, categories: ["documents"], actionCount: 31, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: { kind: "unsupported", reason: diff --git a/server/tests/routes-arcade-urls.test.ts b/server/tests/routes-arcade-urls.test.ts index fceb0bc03..a7ebe1726 100644 --- a/server/tests/routes-arcade-urls.test.ts +++ b/server/tests/routes-arcade-urls.test.ts @@ -46,6 +46,9 @@ function directoryApp(slug: string): BrokerApp { logo: null, categories: [], actionCount: 3, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: { kind: "consent" }, }; } @@ -244,7 +247,7 @@ describe("a Composio deployment answers byte for byte what it did", () => { confirm: '{"connected":true}', accountMode: '{"ended":[],"approved":[]}', directory: - '{"apps":[{"slug":"gmail","name":"Gmail","description":"Mail","logo":null,"categories":[],"actionCount":3,"connection":{"kind":"consent"},"enabled":true}]}', + '{"apps":[{"slug":"gmail","name":"Gmail","description":"Mail","logo":null,"categories":[],"actionCount":3,"classifiedCount":null,"permissionCount":null,"scopedActionCount":null,"connection":{"kind":"consent"},"enabled":true}]}', connect: '{"authorizationUrl":"https://vendor.example/consent"}', }; From 22efed8ec5910b472d6243c222638e12ef193803 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:05:52 +0200 Subject: [PATCH 056/232] feat(arcade): surface the mint id as authorizationId so D24's binding has a source broker.authorize answered only { redirectUrl }; the Arcade adapter read answer.url and discarded answer.id (the ar_... id). Spec D24 (the attempt's ar_... equals the callback's auth_id) is the binding both verifier halves are written against, and it had no source. authorize now answers { redirectUrl, authorizationId: string | null }, REQUIRED AND NULLABLE rather than optional: an omitted field is an inferred absence and a call-site `?? x` would turn "no id" into a value; the null is a declared statement, like grantIsLocal and authorizations. Arcade returns the mint's id (a mint with no id is refused, so a null is never minted from an Arcade answer). Composio returns a declared null: its return leg lands in oauth.ts, correlated by sealed state this deployment minted, and its connection-request id would mean something different behind the same member. The pairing invariant (authorizations null <=> authorizationId null; non-null flow <=> non-null id on a successful mint) is asserted by the new server/tests/broker-authorization-pairing.test.ts, a new file rather than an existing suite because no suite sees every broker literal; it scans both production adapters and every test stub (19 literals). 6.1's toEqual({ redirectUrl }) assertion, which pinned the bug, is updated, as are the Composio authorize assertions and every stub. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 9 +- server/src/plugins/broker.ts | 18 ++- server/src/plugins/composio-adapter.ts | 8 +- server/tests/account-mode.integration.test.ts | 2 +- server/tests/arcade-adapter-authorize.test.ts | 40 +++++- .../arcade-attempt-store.integration.test.ts | 5 +- ...completion-transaction.integration.test.ts | 5 +- .../broker-authorization-pairing.test.ts | 121 ++++++++++++++++++ server/tests/composio-adapter.test.ts | 13 +- .../tests/composio-connection-kinds.test.ts | 5 +- server/tests/composio-grants-for.test.ts | 5 +- server/tests/gate-grant-is-local.test.ts | 5 +- server/tests/plugin-routes.test.ts | 2 +- server/tests/routes-arcade-urls.test.ts | 5 +- server/tests/shared-brokered-routes.test.ts | 5 +- .../shared-brokered-store.integration.test.ts | 5 +- .../shared-gated-mode.integration.test.ts | 5 +- .../tests/shared-wiring.integration.test.ts | 5 +- 18 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 server/tests/broker-authorization-pairing.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 259839c8c..2b15fa956 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -455,7 +455,14 @@ export function createArcadeClient(apiKey: string): { "Arcade answered an authorization with no url to visit.", ); } - return { redirectUrl: url }; + // The mint's own `ar_…` id: spec D24 binds it to the callback's `auth_id`. A mint that + // answered none is not a success, and the refusal is here so that a null can never be + // minted from an Arcade answer — `authorizationId` is non-null for every Arcade mint. + const authorizationId = answer.id; + if (typeof authorizationId !== "string" || authorizationId === "") { + throw new Error("Arcade answered an authorization with no id."); + } + return { redirectUrl: url, authorizationId }; }, // 6.2: the connection-lifecycle trio. isConnected: async ({ account, toolkit }) => diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 4172b0c54..9826e0ed9 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -557,6 +557,22 @@ export type ConnectedAppBroker = { * connection, so it is neither stored nor logged nor put in an audit row: it is handed to the * browser that asked for it and then forgotten. A redirect url in a log is somebody else's * mailbox for as long as it stays valid. + * + * IT ANSWERS THE MINT'S OWN ID BESIDE THE URL, AND THE ID IS REQUIRED AND NULLABLE, NEVER + * OPTIONAL. `authorizationId` is the handle the vendor gave this one mint, which is what a + * return leg is checked against (spec D24: the attempt's id must equal the callback's + * `auth_id`). An OMITTED field is an inferred absence, and the natural `?? something` at a call + * site turns "this broker mints no id" into a value — an empty string or a made-up id that a + * verifier would then compare against. A required nullable field makes the `null` a DECLARED + * STATEMENT, the same shape {@link ConnectedAppBroker.grantIsLocal} and + * {@link ConnectedAppBroker.authorizations} have in this file, and a call site that holds a + * null passes it on as null. + * + * IT PAIRS WITH {@link ConnectedAppBroker.authorizations}: a broker declaring `null` there + * answers `null` here, and a broker declaring a flow answers a non-null id on every successful + * mint. A broker that grew a flow and forgot the id would leave D24 with no source while every + * type still checked, so `broker-authorization-pairing.test.ts` asserts the pair over every + * broker literal in the tree. */ authorize(request: { account: AccountRef; @@ -598,7 +614,7 @@ export type ConnectedAppBroker = { * that needs it must not find the field optional because the last one did not. */ action?: string; - }): Promise<{ redirectUrl: string }>; + }): Promise<{ redirectUrl: string; authorizationId: string | null }>; /** Whether this account is currently attached to this app at the vendor. */ isConnected(request: { account: AccountRef; diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 38ecfe6b4..b7f44e7fa 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -3496,7 +3496,7 @@ export function buildComposioClient( account, toolkit, returnUrl, - }): Promise<{ redirectUrl: string }> { + }): Promise<{ redirectUrl: string; authorizationId: string | null }> { const userId = account.vendorUserId; /* * THE CONFIG THIS DEPLOYMENT ALREADY MADE, AND NO SECOND ONE MADE HERE. @@ -3765,7 +3765,11 @@ export function buildComposioClient( `Composio began a connection to ${toolkit} but answered with no page to visit, so there is nothing to send ${whose(account)} to. An app that is connected by entering a credential rather than by visiting a page cannot be connected from here.`, ); } - return { redirectUrl: page }; + // DECLARED NULL, NOT COMPOSIO'S CONNECTION-REQUEST ID. Composio's return leg never comes + // through this seam: it lands in `oauth.ts`, correlated by sealed state this deployment + // minted, not by an id the broker handed back. The connection-request id would put a value + // with a DIFFERENT MEANING behind the same member — the `accountName` defect again. + return { redirectUrl: page, authorizationId: null }; }, async isConnected({ account, toolkit }): Promise { diff --git a/server/tests/account-mode.integration.test.ts b/server/tests/account-mode.integration.test.ts index 02c49305e..fd9c233a7 100644 --- a/server/tests/account-mode.integration.test.ts +++ b/server/tests/account-mode.integration.test.ts @@ -40,7 +40,7 @@ const broker = { listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "x" }), + authorize: async () => ({ redirectUrl: "x", authorizationId: null }), isConnected: async () => true, revoke: async ({ account }) => { if (account.vendorUserId === failRevokeFor) diff --git a/server/tests/arcade-adapter-authorize.test.ts b/server/tests/arcade-adapter-authorize.test.ts index 283e0e9ef..c11ae4c85 100644 --- a/server/tests/arcade-adapter-authorize.test.ts +++ b/server/tests/arcade-adapter-authorize.test.ts @@ -60,7 +60,45 @@ describe("arcade adapter authorize", () => { const params = seen[0] as Record; expect(params.tool_name).toBe("Github.CreateIssue"); expect(params.user_id).toBe("vendor-u1"); - expect(out).toEqual({ redirectUrl: "https://consent.example/x" }); + // The mint's own `ar_…` id rides out beside the url: spec D24 binds it to the callback's auth_id. + expect(out).toEqual({ + redirectUrl: "https://consent.example/x", + authorizationId: "au_1", + }); + }); + + test("returns the mint's id as authorizationId, untouched", async () => { + stub( + protoOf((c) => c.tools), + "authorize", + () => + Promise.resolve({ id: "ar_abc123", url: "https://consent.example/y" }), + ); + const { broker } = createArcadeClient("never-dialled"); + const out = await broker.authorize({ + account, + toolkit: "Github", + returnUrl: "https://app.example/settings", + action: "Github.CreateIssue", + }); + expect(out.authorizationId).toBe("ar_abc123"); + }); + + test("a mint that answers no id is not a success, so no null is ever minted", async () => { + stub( + protoOf((c) => c.tools), + "authorize", + () => Promise.resolve({ url: "https://consent.example/y" }), + ); + const { broker } = createArcadeClient("never-dialled"); + await expect( + broker.authorize({ + account, + toolkit: "Github", + returnUrl: "https://app.example/settings", + action: "Github.CreateIssue", + }), + ).rejects.toThrow("no id"); }); test("with no action it refuses without dialling", async () => { diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 6e605cbb7..b9749e8cf 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -31,7 +31,10 @@ const broker: ConnectedAppBroker = { listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: "ar_stub", + }), isConnected: async () => true, revoke: async () => true, connectionFields: async () => [], diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index 5f62e6b00..aa065bcce 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -43,7 +43,10 @@ const broker: ConnectedAppBroker = { listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: "ar_stub", + }), isConnected: async () => true, revoke: async () => true, connectionFields: async () => [], diff --git a/server/tests/broker-authorization-pairing.test.ts b/server/tests/broker-authorization-pairing.test.ts new file mode 100644 index 000000000..931f2ed15 --- /dev/null +++ b/server/tests/broker-authorization-pairing.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * THE PAIRING INVARIANT, asserted over every `ConnectedAppBroker` literal in the tree. + * + * A broker with `authorizations === null` answers `authorizationId: null`; a broker with a + * non-null `authorizations` answers a non-null `authorizationId` on a successful mint. It is the + * one way spec D24's binding (the attempt's `ar_…` equals the callback's `auth_id`) rots without a + * type error: an adapter grows a flow surface and forgets the mint id. + * + * A literal is found by its `grantIsLocal:` member, and its members are the lines at that same + * indent. This reads source text because the stubs are module-local and `server/tests` is outside + * `tsc`, so nothing else can see them all. + */ +const ROOT = join(import.meta.dir, ".."); +const SELF = "broker-authorization-pairing.test.ts"; +const files = [ + ...readdirSync(join(ROOT, "tests")).map((f) => join(ROOT, "tests", f)), + ...readdirSync(join(ROOT, "src", "plugins")).map((f) => + join(ROOT, "src", "plugins", f), + ), +].filter((f) => f.endsWith(".ts") && !f.endsWith(SELF)); + +type Literal = { + where: string; + authorizations: "null" | "flow"; + authorize: string; +}; + +const indentOf = (l: string) => /^\s*/.exec(l)![0].length; + +function literalsIn(file: string): Literal[] { + const lines = readFileSync(file, "utf8").split("\n"); + const out: Literal[] = []; + lines.forEach((line, at) => { + const m = /^(\s*)grantIsLocal:\s*(true|false),?\s*$/.exec(line); + if (!m) return; + const indent = m[1]!.length; + let from = at; + while ( + from > 0 && + (lines[from - 1]!.trim() === "" || indentOf(lines[from - 1]!) >= indent) + ) + from--; + let to = at; + while ( + to < lines.length - 1 && + (lines[to + 1]!.trim() === "" || indentOf(lines[to + 1]!) >= indent) + ) + to++; + const block = lines.slice(from, to + 1); + const members = block + .map((l, i) => ({ l, i })) + .filter( + ({ l }) => + indentOf(l) === indent && /^\s*(async\s+)?\w+\??\s*[(:]/.test(l), + ); + const named = (name: string) => + members.findIndex(({ l }) => + new RegExp(`^\\s*(async\\s+)?${name}\\s*[(:]`).test(l), + ); + const a = named("authorizations"); + const z = named("authorize"); + if (z < 0) { + throw new Error( + `${file}:${at + 1}: a broker literal with no authorize member`, + ); + } + // The one literal that omits `authorizations` is composio-grants-for's deliberate + // does-not-compile witness, which is then spread with `authorizations: null` — so an omission + // is read as the null it is completed with there. + const aLine = a < 0 ? "authorizations: null" : block[members[a]!.i]!; + const end = z + 1 < members.length ? members[z + 1]!.i : block.length; + out.push({ + where: `${file.slice(ROOT.length + 1)}:${from + members[z]!.i + 1}`, + authorizations: /authorizations:\s*null\b/.test(aLine) ? "null" : "flow", + authorize: block.slice(members[z]!.i, end).join("\n"), + }); + }); + return out; +} + +const literals = files.flatMap(literalsIn); + +describe("authorizations and authorizationId are declared as a pair", () => { + test("the scan finds the production adapters and the test stubs", () => { + const places = literals.map((l) => l.where); + expect( + places.some((p) => p.startsWith("src/plugins/arcade-adapter.ts")), + ).toBe(true); + expect( + places.some((p) => p.startsWith("src/plugins/composio-adapter.ts")), + ).toBe(true); + expect(literals.length).toBeGreaterThanOrEqual(14); + }); + + for (const literal of literals) { + test(literal.where, () => { + // A stub that never mints (it throws, or is `unasked`) answers no url, so the pair is moot. + const head = literal.authorize.split("\n").slice(0, 3).join("\n"); + const mints = + /redirectUrl/.test(literal.authorize) && + !/\bthrow\b|unasked\(/.test(head); + if (!mints) return; + // The seam type's own `authorizationId: string | null` annotation is not an answer. + const code = literal.authorize.replaceAll( + "authorizationId: string | null", + "", + ); + if (literal.authorizations === "null") { + expect(code).toMatch(/authorizationId:\s*null/); + expect(code).not.toMatch(/authorizationId:(?!\s*null)/); + } else { + expect(code).toMatch(/authorizationId:/); + expect(code).not.toMatch(/authorizationId:\s*null/); + } + }); + } +}); diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 552f4b5bb..25fbbafe5 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -944,6 +944,8 @@ describe("beginning one person's connection", () => { ]); expect(begun).toEqual({ redirectUrl: "https://backend.composio.dev/s/a-link", + // Declared null: Composio's return leg is correlated by sealed state, not by a broker id. + authorizationId: null, }); }); @@ -5765,6 +5767,8 @@ describe("a field Composio padded with whitespace", () => { expect(begun).toEqual({ redirectUrl: "https://backend.composio.dev/s/a-link", + // Declared null: Composio's return leg is correlated by sealed state, not by a broker id. + authorizationId: null, }); }); @@ -5845,6 +5849,8 @@ describe("a field Composio padded with whitespace", () => { expect(begun).toEqual({ redirectUrl: "https://backend.composio.dev/s/a-link", + // Declared null: Composio's return leg is correlated by sealed state, not by a broker id. + authorizationId: null, }); expect(linked).toEqual([ ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], @@ -6155,7 +6161,10 @@ describe("the key becoming a vendor, and which delete that vendor carries", () = }; const minted: unknown[][] = []; - let begun: { redirectUrl: string } | null = null; + let begun: { + redirectUrl: string; + authorizationId: string | null; + } | null = null; try { // The config listing is what carries the auth config's id to the link, so it answers with the // one this deployment made — the id that must arrive SECOND. @@ -6185,6 +6194,8 @@ describe("the key becoming a vendor, and which delete that vendor carries", () = ]); expect(begun).toEqual({ redirectUrl: "https://backend.composio.dev/s/a-link", + // Declared null: Composio's return leg is correlated by sealed state, not by a broker id. + authorizationId: null, }); }); }); diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index e202e7731..b5229f7dc 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -673,7 +673,10 @@ const broker: ConnectedAppBroker = { }, authorize: async (request) => { brokerAsks.push(`authorize:${request.toolkit}`); - return { redirectUrl: `https://composio.test/${request.toolkit}/consent` }; + return { + redirectUrl: `https://composio.test/${request.toolkit}/consent`, + authorizationId: null, + }; }, isConnected: async (request) => { brokerAsks.push( diff --git a/server/tests/composio-grants-for.test.ts b/server/tests/composio-grants-for.test.ts index a748d3a00..6c3b07713 100644 --- a/server/tests/composio-grants-for.test.ts +++ b/server/tests/composio-grants-for.test.ts @@ -220,7 +220,10 @@ describe("the declared sub-surface", () => { listApps: async () => [], ensureAuthConfig: async () => "not-needed" as const, deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "https://openbot.test/x" }), + authorize: async () => ({ + redirectUrl: "https://openbot.test/x", + authorizationId: null, + }), isConnected: async () => false, revoke: async () => false, connectionFields: async () => [], diff --git a/server/tests/gate-grant-is-local.test.ts b/server/tests/gate-grant-is-local.test.ts index 8270e9e3d..54b205a6c 100644 --- a/server/tests/gate-grant-is-local.test.ts +++ b/server/tests/gate-grant-is-local.test.ts @@ -41,7 +41,10 @@ const brokerStub: Omit = { listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), isConnected: async () => true, revoke: async () => true, connectionFields: async () => [], diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 28b94876a..de94478b6 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -1899,7 +1899,7 @@ function brokeredApp( // address it was asked with was this deployment's own. authorized.push(request); if (deployment.authorizeThrows) throw deployment.authorizeThrows; - return { redirectUrl }; + return { redirectUrl, authorizationId: null }; }, connectionFields: async (request: { toolkit: string; diff --git a/server/tests/routes-arcade-urls.test.ts b/server/tests/routes-arcade-urls.test.ts index a7ebe1726..7213566ad 100644 --- a/server/tests/routes-arcade-urls.test.ts +++ b/server/tests/routes-arcade-urls.test.ts @@ -103,7 +103,10 @@ function deployment(provider: BrokerProvider) { listApps: async () => [directoryApp(row.toolkit)], authorize: async (input: { toolkit: string }) => { authorized.push({ toolkit: input.toolkit }); - return { redirectUrl: "https://vendor.example/consent" }; + return { + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }; }, } as unknown as ComposioBroker; diff --git a/server/tests/shared-brokered-routes.test.ts b/server/tests/shared-brokered-routes.test.ts index 02e695c27..34619e7f8 100644 --- a/server/tests/shared-brokered-routes.test.ts +++ b/server/tests/shared-brokered-routes.test.ts @@ -51,7 +51,10 @@ const broker: ConnectedAppBroker = { authorize: async ({ account, returnUrl }) => { authorizedFor.push(account.vendorUserId); returnUrlsSeen.push(returnUrl); - return { redirectUrl: "https://vendor.example/consent" }; + return { + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }; }, isConnected: async () => true, revoke: async ({ account }) => { diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 19fd41d13..86be0fcac 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -41,7 +41,10 @@ const broker: ConnectedAppBroker = { listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), isConnected: async () => true, revoke: async ({ account }) => { revoked.push(account.vendorUserId); diff --git a/server/tests/shared-gated-mode.integration.test.ts b/server/tests/shared-gated-mode.integration.test.ts index 79dcfc082..19a759a9a 100644 --- a/server/tests/shared-gated-mode.integration.test.ts +++ b/server/tests/shared-gated-mode.integration.test.ts @@ -33,7 +33,10 @@ const broker: ConnectedAppBroker = { listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), isConnected: async () => true, revoke: async () => true, connectionFields: async () => [], diff --git a/server/tests/shared-wiring.integration.test.ts b/server/tests/shared-wiring.integration.test.ts index 4c7021ca2..f87dfcaf4 100644 --- a/server/tests/shared-wiring.integration.test.ts +++ b/server/tests/shared-wiring.integration.test.ts @@ -34,7 +34,10 @@ const broker: ConnectedAppBroker = { listApps: async () => [], ensureAuthConfig: async () => "standing", deleteAuthConfig: async () => {}, - authorize: async () => ({ redirectUrl: "https://vendor.example/consent" }), + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), isConnected: async () => true, revoke: async () => true, connectionFields: async () => [], From 1ee5a7bfc188d38bc1e601f4c1a5730072312754 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:12:42 +0200 Subject: [PATCH 057/232] feat(arcade): add POST /servers/:id/authorize to mint a per-action authorization Body { action } answers { authorizationUrl } and nothing else. Order: the capability gate (presence test on broker.authorizations, 503) runs before brokeredAccountFor and before anything is written; then the account; then the action must be one this app publishes (exact, case-sensitive); then the return url is built and guarded; then claimAuthorizationAttempt keyed on the session subject; then the mint; then recordAuthorizationMint with the authorizationId as returned (null stays NULL). Everything that can fail without the vendor runs before the claim. Adds store.serverPublishesAction, one indexed read on mcp_tools, and widens recordAuthorizationMint's authId to string | null. Out of scope: mint-failure handling (7.2) and the DELETE cancel route (7.3). Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 130 ++++++++ server/src/plugins/store.ts | 24 +- server/tests/arcade-authorize-route.test.ts | 343 ++++++++++++++++++++ 3 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 server/tests/arcade-authorize-route.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index a4c755720..29d13d429 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2213,6 +2213,136 @@ export function createPluginRoutes( } }); + /** + * Begin authorizing ONE ACTION of a brokered app, for a provider that grants action by action. + * Answers `{ authorizationUrl }` and nothing else. + * + * ORDERING PRINCIPLE: EVERYTHING THAT CAN FAIL WITHOUT THE VENDOR RUNS BEFORE THE CLAIM; THE + * CLAIM IS THE LAST THING BEFORE THE MINT. A bad `OPENBOT_APP_URL` must not leave a live attempt + * with no mint behind it, and the mint runs after the insert so a mint can never exist without a + * row to correlate it. + * + * THE CAPABILITY GATE IS FIRST, BEFORE `brokeredAccountFor` AND BEFORE ANYTHING IS WRITTEN. A + * broker that declares no authorization flow cannot complete one, so claiming a row for it would + * block the actor for the full attempt lifetime over a flow that was never possible; a gate after + * the claim does not prevent that. It is a presence test on a declared capability and never a + * vendor name. 503 and not 404 or 400: the route exists, so 404 would lie, and the request is not + * what is wrong; 503 is this file's vocabulary for a capability this deployment does not have. + * + * `actorId` IS THE SESSION SUBJECT (`users.id`) AND NEVER A CREDENTIAL ACTOR: the verifier finds + * the attempt from the returning browser's session. + * + * THE URL IS A BEARER CAPABILITY — answered to this browser and to nothing else. It is never + * logged, audited or put in an error body. No `next_uri` is sent: the vendor refuses every value, + * and the landing page comes from the attempt row instead. + * + * NOT HERE: what a failed mint does to its attempt, and cancelling one. + */ + routes.post("/servers/:id/authorize", requireUser, async (context) => { + const serverId = context.req.param("id"); + + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + const flow = composio.broker.authorizations; + if (!flow) { + return context.json( + { + error: + "This deployment's apps are connected once for the whole app, with the Connect button on the app's page; they are not authorized one action at a time.", + }, + 503, + ); + } + + const resolved = await brokeredAccountFor(context, serverId); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + const { account, toolkit, title } = resolved; + + const body = await context.req.json().catch(() => null); + const action = + body && typeof body === "object" && "action" in body + ? (body as { action: unknown }).action + : undefined; + // Exact `===` against the stored names: Arcade's are case-sensitive and never normalized. + if ( + typeof action !== "string" || + !(await store.serverPublishesAction(serverId, action)) + ) { + return context.json( + { error: `${title} does not publish an action by that name.` }, + 400, + ); + } + + if (!connect?.appUrl) { + return context.json( + { + error: + "This deployment has no app URL configured, so there is nowhere to send you back to. Set OPENBOT_APP_URL.", + }, + 503, + ); + } + const returnTo: ConnectOrigin = + account.holder === "deployment" + ? "admin" + : context.req.query("returnTo") === "admin" + ? "admin" + : "settings"; + let returnUrl: string; + try { + returnUrl = brokerReturnUrl( + connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + ); + } catch (error) { + const refusal = brokerRefusal( + error, + "This deployment could not build the address to send you back to, so nothing was started.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + + const claim = await store.claimAuthorizationAttempt({ + actorId: context.var.actor.id, + serverId, + action, + account, + }); + if (!claim.claimed) { + return context.json( + { + error: claim.live + ? `An authorization for ${claim.live.action} is already in progress. Finish it, or wait for it to expire, before starting another.` + : "An authorization is already in progress. Finish it, or wait for it to expire, before starting another.", + }, + 409, + ); + } + + const minted = await composio.broker.authorize({ + account, + toolkit, + action, + returnUrl, + }); + // A null id is recorded as NULL: an attempt holding none can never complete and simply expires. + await store.recordAuthorizationMint({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + authId: minted.authorizationId, + }); + return context.json({ authorizationUrl: minted.redirectUrl }); + }); + /** * Where the vendor sends somebody back. * diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 9636cd82f..5c3e0b66f 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -4849,6 +4849,27 @@ export function createPluginStore(options: PluginStoreOptions) { return row !== undefined; }, + /** + * Whether this server publishes an action named EXACTLY this. + * + * One indexed read on `mcp_tools`' `(server_id, name)` key, and deliberately not `listServers`, + * which materialises every tool and every grant in the deployment to answer. The comparison is + * `=` on the stored text and so CASE-SENSITIVE: Arcade names are dotted and case-sensitive and + * are never normalized, and `Gmail.SendEmail` is not `gmail.sendemail`. + */ + async serverPublishesAction( + serverId: string, + action: string, + ): Promise { + if (!serverId || !action) return false; + const [row] = await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, action))) + .limit(1); + return row !== undefined; + }, + async agentOwner(agentId: string): Promise { const [row] = await database .select({ ownerUserId: agentProfiles.ownerUserId }) @@ -6067,7 +6088,8 @@ export function createPluginStore(options: PluginStoreOptions) { async recordAuthorizationMint(input: { actorId: string; createdAt: Date; - authId: string; + /** Null when the vendor answered with no id: the attempt then can never complete. */ + authId: string | null; }): Promise { const written = await database .update(pendingAuthorizations) diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts new file mode 100644 index 000000000..1c59733ff --- /dev/null +++ b/server/tests/arcade-authorize-route.test.ts @@ -0,0 +1,343 @@ +// server/tests/arcade-authorize-route.test.ts +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray, sql } from "drizzle-orm"; +import { Hono, type MiddlewareHandler } from "hono"; +import { createAuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { createDatabase } from "../src/db/client"; +import { mcpServers, mcpTools, pendingAuthorizations } from "../src/db/schema"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginRoutes } from "../src/plugins/routes"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const personalApp = `arcade-p-${suite}`; +const sharedApp = `arcade-s-${suite}`; +const composioApp = `composio-p-${suite}`; +const SHARED_VENDOR_ID = `openbot-deployment:acme-${suite}:R`; +const ACTION = "Gmail.SendEmail"; +const SECRET_URL = "https://vendor.example/consent?secret=bearer-capability"; + +let mints: { vendorUserId: string; action?: string; toolkit: string }[] = []; +let nextAuthId: string | null = "ar_minted_1"; + +const arcadeBroker: ConnectedAppBroker = { + grantIsLocal: false, + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async ({ account, toolkit, action }) => { + mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); + return { redirectUrl: SECRET_URL, authorizationId: nextAuthId }; + }, + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme-bot", +}; + +const composioBroker: ConnectedAppBroker = { + ...arcadeBroker, + grantIsLocal: true, + authorizations: null, + authorize: async ({ account, toolkit, action }) => { + mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); + return { redirectUrl: SECRET_URL, authorizationId: null }; + }, +}; + +function appAs( + role: "admin" | "user", + id: string, + provider: BrokerProvider = "arcade", +) { + const broker = provider === "arcade" ? arcadeBroker : composioBroker; + const store = createPluginStore({ + brokerProvider: provider, + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { id, email: `${id}@example.test`, role } as never); + await next(); + }; + return new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + async () => true, + { + publicUrl: "https://openbot.example", + appUrl: "https://app.example", + encryptionKey: `${"A".repeat(43)}=`, + personHasAccess: async () => true, + }, + { broker, provider }, + ), + ); +} + +const actors: string[] = []; +const person = (name: string) => { + const id = `${name}-${suite}`; + actors.push(id); + return id; +}; + +const authorize = ( + app: Hono, + serverId: string, + body: unknown = { action: ACTION }, +) => + app.request(`/api/plugins/servers/${serverId}/authorize`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +const attemptsFor = (actorId: string) => + database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, actorId)); + +beforeAll(async () => { + await database.insert(mcpServers).values([ + { + id: personalApp, + title: "Gmail", + vendor: "Arcade", + url: `arcade://P${suite}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "personal", + }, + { + id: sharedApp, + title: "Team Gmail", + vendor: "Arcade", + url: `arcade://S${suite}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "shared", + sharedVendorUserId: SHARED_VENDOR_ID, + }, + { + id: composioApp, + title: "Composio Gmail", + vendor: "Composio", + url: `composio://c${suite}`, + provenance: "composio", + authScheme: "OAUTH2", + }, + ]); + await database.insert(mcpTools).values( + [personalApp, sharedApp, composioApp].map((serverId) => ({ + serverId, + name: ACTION, + })), + ); +}); + +afterAll(async () => { + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, [personalApp, sharedApp, composioApp])); + if (actors.length) { + await database + .delete(pendingAuthorizations) + .where(inArray(pendingAuthorizations.actorId, actors)); + } +}); + +beforeEach(() => { + mints = []; + nextAuthId = "ar_minted_1"; +}); + +describe("POST /servers/:id/authorize", () => { + test("mints for the named action and the row records the exact account and the authorization id", async () => { + const actor = person("mint"); + const response = await authorize(appAs("user", actor), personalApp); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ authorizationUrl: SECRET_URL }); + expect(mints).toEqual([ + { vendorUserId: actor, action: ACTION, toolkit: `P${suite}` }, + ]); + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ + actorId: actor, + serverId: personalApp, + action: ACTION, + holder: "person", + userId: actor, + vendorUserId: actor, + authId: "ar_minted_1", + completedAt: null, + outcome: null, + }); + // created_at round-trips through a JS Date: the mint matched on it, and the stored value is + // exactly millisecond-precise. + const created = row?.createdAt as Date; + expect(new Date(created.getTime()).getTime()).toBe(created.getTime()); + const [exact] = (await database.execute( + sql`select (created_at = date_trunc('milliseconds', created_at)) as exact + from pending_authorizations where actor_id = ${actor}`, + )) as unknown as { exact: boolean }[]; + expect(exact?.exact).toBe(true); + }); + + test("a null authorization id is recorded as NULL, never collapsed", async () => { + const actor = person("nullid"); + nextAuthId = null; + const response = await authorize(appAs("user", actor), personalApp); + expect(response.status).toBe(200); + const [row] = await attemptsFor(actor); + expect(row?.authId).toBeNull(); + expect(row?.completedAt).toBeNull(); + }); + + test("an action the app does not publish is a 400 with no attempt and no mint", async () => { + const actor = person("unpublished"); + const response = await authorize(appAs("user", actor), personalApp, { + action: "Gmail.DeleteEverything", + }); + expect(response.status).toBe(400); + expect(mints).toEqual([]); + expect(await attemptsFor(actor)).toEqual([]); + }); + + test("a differently-cased action is not accepted", async () => { + const actor = person("cased"); + const response = await authorize(appAs("user", actor), personalApp, { + action: ACTION.toLowerCase(), + }); + expect(response.status).toBe(400); + expect(mints).toEqual([]); + expect(await attemptsFor(actor)).toEqual([]); + }); + + test("a non-admin on a Shared app is a 403 with no attempt", async () => { + const actor = person("shared-user"); + const response = await authorize(appAs("user", actor), sharedApp); + expect(response.status).toBe(403); + expect(mints).toEqual([]); + expect(await attemptsFor(actor)).toEqual([]); + }); + + test("an admin on a Shared app records the deployment's account on the row", async () => { + const actor = person("shared-admin"); + const response = await authorize(appAs("admin", actor), sharedApp); + expect(response.status).toBe(200); + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ + holder: "deployment", + userId: null, + vendorUserId: SHARED_VENDOR_ID, + authId: "ar_minted_1", + }); + }); + + test("a second authorization while one is live is a 409 naming the first", async () => { + const actor = person("live"); + const app = appAs("user", actor); + expect((await authorize(app, personalApp)).status).toBe(200); + const [first] = await attemptsFor(actor); + mints = []; + const second = await authorize(app, personalApp); + expect(second.status).toBe(409); + expect(JSON.stringify(await second.json())).toContain(ACTION); + expect(mints).toEqual([]); + const [after] = await attemptsFor(actor); + expect(after?.createdAt.getTime()).toBe( + first?.createdAt.getTime() as number, + ); + expect(after?.authId).toBe("ar_minted_1"); + }); + + test("an authorization after the first expired succeeds, replacing it", async () => { + const actor = person("expired"); + const app = appAs("user", actor); + expect((await authorize(app, personalApp)).status).toBe(200); + await database.execute( + sql`update pending_authorizations set created_at = created_at - interval '16 minutes' where actor_id = ${actor}`, + ); + const [old] = await attemptsFor(actor); + nextAuthId = "ar_minted_2"; + expect((await authorize(app, personalApp)).status).toBe(200); + const rows = await attemptsFor(actor); + expect(rows).toHaveLength(1); + expect(rows[0]?.authId).toBe("ar_minted_2"); + expect(rows[0]?.createdAt.getTime()).toBeGreaterThan( + old?.createdAt.getTime() as number, + ); + }); + + test("the authorization url appears in no audit row", async () => { + const actor = person("audit"); + expect((await authorize(appAs("user", actor), personalApp)).status).toBe( + 200, + ); + const leaked = await database.execute( + sql`select id from audit_events + where payload::text like ${"%bearer-capability%"} + or target_id like ${"%bearer-capability%"}`, + ); + expect(leaked.length).toBe(0); + }); + + test("a deployment whose broker runs no authorization flow answers 503 and writes no attempt", async () => { + const actor = person("composio"); + const response = await authorize( + appAs("user", actor, "composio"), + composioApp, + ); + expect(response.status).toBe(503); + expect(mints).toEqual([]); + expect(await attemptsFor(actor)).toEqual([]); + expect(JSON.stringify(await response.json())).not.toContain( + "vendor.example", + ); + }); + + test("the gate answers before whose account it is: a non-admin on a Shared app of such a deployment is a 503, not a 403", async () => { + const actor = person("composio-shared"); + const response = await authorize( + appAs("user", actor, "composio"), + sharedApp, + ); + expect(response.status).toBe(503); + expect(await attemptsFor(actor)).toEqual([]); + }); +}); From 04301f0177f884804d1f9c851870721e68a6e4ca Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:15:19 +0200 Subject: [PATCH 058/232] feat(arcade): decide a failed mint's attempt by what the vendor answered A definite refusal (an authored BrokerRefusalError, or an SDK error carrying an HTTP status) discards the attempt, matched on (actor_id, created_at) so a late cleanup can never delete a newer attempt by the same actor. An uncertain outcome (SDK error with status === undefined, i.e. APIConnectionError / APIUserAbortError, which extend APIError) and a non-SDK throw (the adapter's missing-id Error, where Arcade very likely did create a flow) leave the attempt standing with a null auth_id to expire. Answers go through brokerRefusal; the vendor's body is never logged, audited or returned. Out of scope: no new route (the DELETE cancel route is 7.3's); no status code, contract or response shape of any other route changes. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 66 +++++++- server/tests/arcade-authorize-route.test.ts | 159 ++++++++++++++++++++ 2 files changed, 218 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 29d13d429..f7abfcae4 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -7,6 +7,7 @@ import { requireAdmin } from "../auth/guards"; import { reasonWithoutStatement } from "../db/query-failure"; import type { AccountModeSwitch } from "./account-mode"; import { type BrokerProvider, toolkitOfFor } from "./access"; +import { normalizeArcadeError } from "./arcade-adapter"; import { type BrokerApp, type BrokerField, @@ -2236,7 +2237,7 @@ export function createPluginRoutes( * logged, audited or put in an error body. No `next_uri` is sent: the vendor refuses every value, * and the landing page comes from the attempt row instead. * - * NOT HERE: what a failed mint does to its attempt, and cancelling one. + * A FAILED MINT'S ATTEMPT is handled at the mint's `catch`. NOT HERE: cancelling one. */ routes.post("/servers/:id/authorize", requireUser, async (context) => { const serverId = context.req.param("id"); @@ -2328,12 +2329,63 @@ export function createPluginRoutes( ); } - const minted = await composio.broker.authorize({ - account, - toolkit, - action, - returnUrl, - }); + let minted: Awaited>; + try { + minted = await composio.broker.authorize({ + account, + toolkit, + action, + returnUrl, + }); + } catch (error) { + /* + * WHAT A FAILED MINT DOES TO ITS ATTEMPT. THREE OUTCOMES, KEYED ON WHAT WAS ANSWERED. + * + * DEFINITE: the broker refused before asking (an authored `BrokerRefusalError`), or the + * vendor ANSWERED with an HTTP status. Nothing is live upstream, so the attempt is discarded + * and the actor is free to try again. Matched on `(actor_id, created_at)`, never `auth_id`: an + * attempt whose mint never completed has none, and `created_at` is what stops a late cleanup + * from deleting a NEWER attempt by the same actor. + * + * UNCERTAIN, AND THE ATTEMPT IS LEFT STANDING: `status === undefined` on a normalized SDK + * error. `APIConnectionError` and `APIUserAbortError` EXTEND `APIError`, so the normalizer + * answers `{ status: undefined, ... }` for them, NOT `null`; a dropped connection or an + * aborted socket means Arcade may well have created a flow. Keying this on `null` would class + * a timeout as a refusal and delete an attempt whose flow may be live, which frees the actor + * to start a second flow beside it. An attempt holding a null `auth_id` can never complete, + * so leaving it to expire is safe, and it is the point. + * + * THE NORMALIZER ANSWERING `null` IS UNCERTAIN TOO. That is "not an SDK error at all", and + * the adapter throws exactly such an `Error` ("Arcade answered an authorization with no id.") + * for a mint that answered SUCCESSFULLY without an `ar_...`: Arcade very likely did create a + * flow. Do not tidy this case into the definite branch. + * + * NOTHING FROM THE VENDOR'S ANSWER IS LOGGED, AUDITED OR RETURNED: its body can carry the + * authorization url, a bearer capability. + */ + const authored = brokerSentence(error) !== null; + const failure = normalizeArcadeError(error); + const definite = + authored || (failure !== null && failure.status !== undefined); + if (definite) { + await store.discardAuthorizationAttempt({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + }); + const refusal = brokerRefusal( + error, + "The app's vendor refused to start that authorization, so nothing was started. Try again in a moment.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + return context.json( + { + error: + "The app's vendor did not answer clearly, and the authorization may or may not have started. Wait for it to expire, or finish it if you were sent to a consent screen, before starting another.", + }, + 502, + ); + } // A null id is recorded as NULL: an attempt holding none can never complete and simply expires. await store.recordAuthorizationMint({ actorId: context.var.actor.id, diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index 1c59733ff..cad89c928 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -7,6 +7,7 @@ import { expect, test, } from "bun:test"; +import { APIConnectionError, APIError } from "@arcadeai/arcadejs"; import { randomUUID } from "node:crypto"; import { eq, inArray, sql } from "drizzle-orm"; import { Hono, type MiddlewareHandler } from "hono"; @@ -31,6 +32,8 @@ const SECRET_URL = "https://vendor.example/consent?secret=bearer-capability"; let mints: { vendorUserId: string; action?: string; toolkit: string }[] = []; let nextAuthId: string | null = "ar_minted_1"; +// What the next mint does before answering; a test sets it to throw, or to hold the mint open. +let beforeMint: (() => Promise) | null = null; const arcadeBroker: ConnectedAppBroker = { grantIsLocal: false, @@ -48,6 +51,7 @@ const arcadeBroker: ConnectedAppBroker = { deleteAuthConfig: async () => {}, authorize: async ({ account, toolkit, action }) => { mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); + await beforeMint?.(); return { redirectUrl: SECRET_URL, authorizationId: nextAuthId }; }, isConnected: async () => true, @@ -184,6 +188,7 @@ afterAll(async () => { beforeEach(() => { mints = []; nextAuthId = "ar_minted_1"; + beforeMint = null; }); describe("POST /servers/:id/authorize", () => { @@ -340,4 +345,158 @@ describe("POST /servers/:id/authorize", () => { expect(response.status).toBe(503); expect(await attemptsFor(actor)).toEqual([]); }); + + describe("a mint that fails", () => { + const refuse = async () => { + throw new APIError( + 500, + { name: "boom", message: "vendor-body-secret" }, + "500", + new Headers(), + ); + }; + + test("a definite vendor refusal deletes the attempt, and a late cleanup can never delete a newer one", async () => { + const actor = person("definite"); + beforeMint = refuse; + const response = await authorize(appAs("user", actor), personalApp); + expect(response.status).toBe(502); + expect(JSON.stringify(await response.json())).not.toContain( + "vendor-body-secret", + ); + expect(await attemptsFor(actor)).toEqual([]); + + // The actor starts again; the first attempt's cleanup then arrives late. + beforeMint = null; + nextAuthId = "ar_newer"; + expect((await authorize(appAs("user", actor), personalApp)).status).toBe( + 200, + ); + const [newer] = await attemptsFor(actor); + const store = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker: arcadeBroker, + deploymentId: `acme-${suite}`, + }); + const staleCreatedAt = new Date( + (newer?.createdAt.getTime() as number) - 5, + ); + expect( + await store.discardAuthorizationAttempt({ + actorId: actor, + createdAt: staleCreatedAt, + }), + ).toBe(false); + const [survivor] = await attemptsFor(actor); + expect(survivor).toMatchObject({ + authId: "ar_newer", + completedAt: null, + outcome: null, + }); + expect(survivor?.createdAt.getTime()).toBe(newer?.createdAt.getTime()); + }); + + test("an authored refusal is definite: nothing reached the vendor, so the attempt is deleted", async () => { + const actor = person("authored"); + const { BrokerRefusalError } = await import("../src/plugins/broker"); + beforeMint = async () => { + throw new BrokerRefusalError("Arcade needs an action."); + }; + const response = await authorize(appAs("user", actor), personalApp); + expect(response.status).toBe(503); + expect(await attemptsFor(actor)).toEqual([]); + }); + + test("an uncertain failure (status undefined) leaves the attempt standing with a null auth_id", async () => { + const actor = person("uncertain"); + beforeMint = async () => { + throw new APIConnectionError({ message: "socket dropped" }); + }; + const response = await authorize(appAs("user", actor), personalApp); + expect(response.status).toBe(502); + const body = JSON.stringify(await response.json()); + expect(body).not.toContain("bearer-capability"); + const rows = await attemptsFor(actor); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + authId: null, + completedAt: null, + outcome: null, + }); + const created = rows[0]?.createdAt as Date; + expect(new Date(created.getTime()).getTime()).toBe(created.getTime()); + // ...and it still holds the actor: a second start is a 409, not a second flow. + beforeMint = null; + expect((await authorize(appAs("user", actor), personalApp)).status).toBe( + 409, + ); + }); + + test("a non-SDK throw (the adapter's missing-id Error) is uncertain too: the attempt stands", async () => { + const actor = person("noid"); + beforeMint = async () => { + throw new Error("Arcade answered an authorization with no id."); + }; + const response = await authorize(appAs("user", actor), personalApp); + expect(response.status).toBe(502); + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ + authId: null, + completedAt: null, + outcome: null, + }); + }); + }); + + test("two concurrent starts by one actor yield exactly one 200 and one 409", async () => { + const actor = person("race"); + // Hold the winner's mint open so the loser's claim runs while it is in flight. + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + beforeMint = () => held; + const app = appAs("user", actor); + const first = authorize(app, personalApp); + const second = authorize(app, personalApp); + const settled = Promise.all([first, second]); + setTimeout(release, 150); + const responses = await settled; + expect(responses.map((r) => r.status).sort()).toEqual([200, 409]); + expect(mints).toHaveLength(1); + const rows = await attemptsFor(actor); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + authId: "ar_minted_1", + completedAt: null, + outcome: null, + }); + }); + + test("a Shared app is authorized under the SESSION SUBJECT, not the deployment's credential account", async () => { + const actor = person("team-bot"); + expect((await authorize(appAs("admin", actor), sharedApp)).status).toBe( + 200, + ); + const rows = await attemptsFor(actor); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actorId: actor, + holder: "deployment", + vendorUserId: SHARED_VENDOR_ID, + completedAt: null, + outcome: null, + }); + expect(rows[0]?.actorId).not.toBe(SHARED_VENDOR_ID); + const stray = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, SHARED_VENDOR_ID)); + expect(stray).toEqual([]); + }); }); From 68e34c4dd0beed3b414607bdfc5afa463a8ca38c Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:17:23 +0200 Subject: [PATCH 059/232] feat(arcade): add DELETE /servers/:id/authorize to cancel this actor's attempt Matched on (actor_id, created_at), never auth_id, via discardAuthorizationAttempt. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 35 +++ server/tests/arcade-authorize-cancel.test.ts | 270 +++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 server/tests/arcade-authorize-cancel.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index f7abfcae4..8478ea802 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2395,6 +2395,41 @@ export function createPluginRoutes( return context.json({ authorizationUrl: minted.redirectUrl }); }); + /** + * Cancel THIS actor's authorization attempt, so somebody who closed the consent tab need not wait + * out the fifteen-minute TTL. Answers `{ cancelled }`: whether an attempt was removed. + * + * CANCELLING WHEN NO ATTEMPT EXISTS IS NOT AN ERROR — the attempt may have expired, completed or + * been cancelled already, and the person's intent (no attempt standing) is met either way. + * + * MATCHED ON `(actor_id, created_at)`, NEVER ON `auth_id`: an attempt whose mint never completed + * has no `auth_id` and is exactly the one most worth cancelling, and `created_at` is what stops + * this from deleting a NEWER attempt by the same actor that replaced the one resolved here. It + * goes through `discardAuthorizationAttempt`, the same deletion the mint's definite-failure + * discard uses. + * + * `actorId` IS THE SESSION SUBJECT (`users.id`), NEVER A CREDENTIAL ACTOR. BEHIND `requireUser` + * AND NOT ADMIN-GATED AT THE ROUTE, as the disconnect route above: `brokeredAccountFor` is where + * a Shared app is refused to anybody but an administrator. + */ + routes.delete("/servers/:id/authorize", requireUser, async (context) => { + const resolved = await brokeredAccountFor(context, context.req.param("id")); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + const actorId = context.var.actor.id; + const attempt = await store.authorizationAttemptFor(actorId); + if (!attempt) return context.json({ cancelled: false }); + const cancelled = await store.discardAuthorizationAttempt({ + actorId, + createdAt: attempt.createdAt, + }); + return context.json({ cancelled }); + }); + /** * Where the vendor sends somebody back. * diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts new file mode 100644 index 000000000..bb7af3ebd --- /dev/null +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -0,0 +1,270 @@ +// server/tests/arcade-authorize-cancel.test.ts +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray, sql } from "drizzle-orm"; +import { Hono, type MiddlewareHandler } from "hono"; +import { createAuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { createDatabase } from "../src/db/client"; +import { mcpServers, mcpTools, pendingAuthorizations } from "../src/db/schema"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginRoutes } from "../src/plugins/routes"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const personalApp = `arcade-cp-${suite}`; +const sharedApp = `arcade-cs-${suite}`; +const SHARED_VENDOR_ID = `openbot-deployment:acme-${suite}:R`; +const ACTION = "Gmail.SendEmail"; +const SECRET_URL = "https://vendor.example/consent?secret=bearer-capability"; + +let mints: { vendorUserId: string; action?: string; toolkit: string }[] = []; +let nextAuthId: string | null = "ar_minted_1"; +// What the next mint does before answering; a test sets it to throw, or to hold the mint open. +let beforeMint: (() => Promise) | null = null; + +const arcadeBroker: ConnectedAppBroker = { + grantIsLocal: false, + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async ({ account, toolkit, action }) => { + mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); + await beforeMint?.(); + return { redirectUrl: SECRET_URL, authorizationId: nextAuthId }; + }, + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme-bot", +}; + +const composioBroker: ConnectedAppBroker = { + ...arcadeBroker, + grantIsLocal: true, + authorizations: null, + authorize: async ({ account, toolkit, action }) => { + mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); + return { redirectUrl: SECRET_URL, authorizationId: null }; + }, +}; + +function appAs( + role: "admin" | "user", + id: string, + provider: BrokerProvider = "arcade", + wrap: ( + store: ReturnType, + ) => ReturnType = (s) => s, +) { + const broker = provider === "arcade" ? arcadeBroker : composioBroker; + const baseStore = createPluginStore({ + brokerProvider: provider, + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + const store = wrap(baseStore); + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { id, email: `${id}@example.test`, role } as never); + await next(); + }; + return new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + async () => true, + { + publicUrl: "https://openbot.example", + appUrl: "https://app.example", + encryptionKey: `${"A".repeat(43)}=`, + personHasAccess: async () => true, + }, + { broker, provider }, + ), + ); +} + +const actors: string[] = []; +const person = (name: string) => { + const id = `${name}-${suite}`; + actors.push(id); + return id; +}; + +const authorize = (app: Hono, serverId: string) => + app.request(`/api/plugins/servers/${serverId}/authorize`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: ACTION }), + }); +const cancel = (app: Hono, serverId: string) => + app.request(`/api/plugins/servers/${serverId}/authorize`, { + method: "DELETE", + }); + +const attemptsFor = (actorId: string) => + database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, actorId)); + +beforeAll(async () => { + await database.insert(mcpServers).values([ + { + id: personalApp, + title: "Gmail", + vendor: "Arcade", + url: `arcade://P${suite}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "personal", + }, + { + id: sharedApp, + title: "Team Gmail", + vendor: "Arcade", + url: `arcade://S${suite}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "shared", + sharedVendorUserId: SHARED_VENDOR_ID, + }, + ]); + await database + .insert(mcpTools) + .values( + [personalApp, sharedApp].map((serverId) => ({ serverId, name: ACTION })), + ); +}); + +afterAll(async () => { + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, [personalApp, sharedApp])); + if (actors.length) { + await database + .delete(pendingAuthorizations) + .where(inArray(pendingAuthorizations.actorId, actors)); + } +}); + +beforeEach(() => { + mints = []; + nextAuthId = "ar_minted_1"; + beforeMint = null; +}); + +describe("DELETE /servers/:id/authorize", () => { + test("cancelling removes the attempt and a fresh authorization immediately succeeds", async () => { + const actor = person("cancel"); + const app = appAs("user", actor); + expect((await authorize(app, personalApp)).status).toBe(200); + expect(await attemptsFor(actor)).toHaveLength(1); + + const response = await cancel(app, personalApp); + expect(response.status).toBeLessThan(300); + expect(await attemptsFor(actor)).toEqual([]); + + nextAuthId = "ar_minted_2"; + const again = await authorize(app, personalApp); + expect(again.status).toBe(200); + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ + authId: "ar_minted_2", + completedAt: null, + outcome: null, + }); + // created_at round-trips through a JS Date: it is what the cancel matches on. + const created = row?.createdAt as Date; + const [exact] = (await database.execute( + sql`select (created_at = ${created.toISOString()}::timestamptz) as exact + from pending_authorizations where actor_id = ${actor}`, + )) as unknown as { exact: boolean }[]; + expect(exact?.exact).toBe(true); + }); + + test("cancelling an attempt whose mint never completed (no auth_id) removes it", async () => { + const actor = person("noid"); + nextAuthId = null; + const app = appAs("user", actor); + expect((await authorize(app, personalApp)).status).toBe(200); + const [before] = await attemptsFor(actor); + expect(before?.authId).toBeNull(); + expect((await cancel(app, personalApp)).status).toBeLessThan(300); + expect(await attemptsFor(actor)).toEqual([]); + }); + + test("cancelling when no attempt exists is not an error", async () => { + const actor = person("none"); + const response = await cancel(appAs("user", actor), personalApp); + expect(response.status).toBeLessThan(300); + expect(await attemptsFor(actor)).toEqual([]); + }); + + test("a cancel issued for an OLD created_at does not remove a NEWER attempt by the same actor", async () => { + const actor = person("stale"); + const plain = appAs("user", actor); + expect((await authorize(plain, personalApp)).status).toBe(200); + const [old] = await attemptsFor(actor); + if (!old) throw new Error("no first attempt"); + // The first attempt expires and a newer one replaces it, completing-in-flight with its own state. + await database.execute( + sql`update pending_authorizations set created_at = created_at - interval '16 minutes' where actor_id = ${actor}`, + ); + const stale = { + ...old, + createdAt: new Date(old.createdAt.getTime() - 16 * 60 * 1000), + }; + nextAuthId = "ar_newer"; + expect((await authorize(plain, personalApp)).status).toBe(200); + const [newer] = await attemptsFor(actor); + expect(newer?.createdAt.getTime()).toBeGreaterThan( + stale.createdAt.getTime(), + ); + + // The cancel resolves the attempt it was issued for (the old one) and then loses the race. + const racing = appAs("user", actor, "arcade", (store) => ({ + ...store, + authorizationAttemptFor: async () => stale, + })); + expect((await cancel(racing, personalApp)).status).toBeLessThan(300); + + const [after] = await attemptsFor(actor); + expect(after?.createdAt.getTime()).toBe( + newer?.createdAt.getTime() as number, + ); + expect(after?.authId).toBe("ar_newer"); + expect(after?.completedAt).toBeNull(); + expect(after?.outcome).toBeNull(); + }); +}); From 36358b34d8f4066e380f9b2ee54a08ee3901aca9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:41:59 +0200 Subject: [PATCH 060/232] feat(arcade): the admin plugins pages carry the active provider's vocabulary only Filtering by brokered-ness reads the row (brokeredProvenance); naming a vendor reads pluginsProvider(page). The two filter sites in $key.tsx need no provider. Exported vocabulary, in app/src/lib/plugins/provider-vocabulary.ts (12.1 / 12.2 reuse): type PluginsProvider = "composio" | "arcade" brokeredProvenance(provenance: string): boolean providerVocabulary(provider: PluginsProvider): { name; directoryLabel; envVar; directoryPath } moreAppsSectionDescription(provider): string moreAppsRow(provider, configured: boolean): { title; description; linked } brokeredConnectionDescription(provider): string brokeredReach(provider): { description; badge } brokeredDisconnectNote(provider): string directoryRouteDecision(routeProvider, page: PluginsPage | undefined): "pending" | "render" | "redirect" /admin/plugins/composio now redirects to /admin/plugins on a non-Composio deployment and draws nothing (so makes no directory request) until the page names the provider. The Arcade directory path is /admin/plugins/arcade in providerVocabulary, but no such screen or route exists yet, so the Arcade "More apps" row links nowhere. No server-id reservation added (4.6 owns it). composioConfigured is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/plugins/provider-vocabulary.ts | 135 ++++++++++++++++++ app/src/routes/_authed/admin/plugins/$key.tsx | 25 ++-- .../routes/_authed/admin/plugins/composio.tsx | 19 ++- .../routes/_authed/admin/plugins/index.tsx | 29 ++-- app/tests/arcade-admin-plugins.test.tsx | 98 +++++++++++++ 5 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 app/src/lib/plugins/provider-vocabulary.ts create mode 100644 app/tests/arcade-admin-plugins.test.tsx diff --git a/app/src/lib/plugins/provider-vocabulary.ts b/app/src/lib/plugins/provider-vocabulary.ts new file mode 100644 index 000000000..af3732b27 --- /dev/null +++ b/app/src/lib/plugins/provider-vocabulary.ts @@ -0,0 +1,135 @@ +import { type PluginsPage, pluginsProvider } from "./queries"; + +/** + * The words an admin Plugins screen uses for the ACTIVE broker, and nothing of the other's (spec D11). + * + * TWO DIFFERENT QUESTIONS, AND CONFLATING THEM IS THE TRAP. A screen that FILTERS by brokered-ness + * needs no provider name: one provider holds rows at a time (spec D3), so {@link brokeredProvenance} + * answers it from the row. A screen that NAMES a vendor cannot derive the name from a row that + * happens to exist — a deployment with the key set and no app enabled yet has no row at all — so it + * reads {@link pluginsProvider} and asks this module for the words. + * + * Pure functions, so the vocabulary is pinned without a DOM. Nothing here carries a key or a + * capability: the provider is a NAME. + */ +export type PluginsProvider = NonNullable; + +/** Whether a row came through a broker, whichever one. Filters; never names. */ +export function brokeredProvenance(provenance: string): boolean { + return provenance === "composio" || provenance === "arcade"; +} + +export type ProviderVocabulary = { + /** The vendor's display name. */ + name: string; + /** The directory entry's label, as the "More apps" row and the picker title say it. */ + directoryLabel: string; + /** The environment variable an operator sets to turn the broker on. */ + envVar: string; + /** The route path of the vendor's directory screen. */ + directoryPath: string; +}; + +const VOCABULARY: Record = { + composio: { + name: "Composio", + directoryLabel: "Browse Composio", + envVar: "COMPOSIO_API_KEY", + directoryPath: "/admin/plugins/composio", + }, + arcade: { + name: "Arcade", + directoryLabel: "Browse Arcade", + envVar: "ARCADE_API_KEY", + directoryPath: "/admin/plugins/arcade", + }, +}; + +export function providerVocabulary( + provider: PluginsProvider, +): ProviderVocabulary { + return VOCABULARY[provider]; +} + +/** The "More apps" section's own sentence. */ +export function moreAppsSectionDescription(provider: PluginsProvider): string { + return `A directory this deployment has not reviewed, reached through ${VOCABULARY[provider].name}.`; +} + +/** + * The one row under "More apps". + * + * `linked` is whether the row goes anywhere. Only Composio has a directory screen today, so an + * Arcade row states its setting and links nowhere; a Composio row links exactly when configured. + */ +export type MoreAppsRow = { + title: string; + description: string; + linked: boolean; +}; + +export function moreAppsRow( + provider: PluginsProvider, + configured: boolean, +): MoreAppsRow { + const { name, directoryLabel, envVar } = VOCABULARY[provider]; + if (!configured) { + return { + title: name, + description: `Add your ${name} key to enable a catalogue of tools. Set ${envVar} on this deployment.`, + linked: false, + }; + } + if (provider === "composio") { + return { + title: directoryLabel, + description: + "A few hundred apps, reached as whoever is asking. Each person connects their own account.", + linked: true, + }; + } + return { + title: name, + description: `${name} is configured on this deployment. Each person connects their own account.`, + linked: false, + }; +} + +/** The Connection card's sentence for a brokered app. */ +export function brokeredConnectionDescription( + provider: PluginsProvider, +): string { + return `Reached through ${VOCABULARY[provider].name}, which holds each person's own account.`; +} + +/** The "How this is reached" row for a brokered app: its sentence and its badge. */ +export function brokeredReach(provider: PluginsProvider): { + description: string; + badge: string; +} { + const { name } = VOCABULARY[provider]; + return { + description: `This app is reached through ${name}, which holds the account. This deployment sends one key and the name of whoever is asking, so each person connects their own and a Bot sees only what that person can see. There is no token to paste.`, + badge: `Through ${name}`, + }; +} + +/** What disconnecting a brokered account does, said where the button is. */ +export function brokeredDisconnectNote(provider: PluginsProvider): string { + return `Disconnecting ends the account at ${VOCABULARY[provider].name}, not just here.`; +} + +/** + * Whether a directory route may answer on this deployment. + * + * `pending` until the page has said which provider this is: the default of `"composio"` is right for + * a label and wrong for a gate, because acting on it would fire a Composio request against an Arcade + * project in the moment before the answer arrives. + */ +export function directoryRouteDecision( + routeProvider: PluginsProvider, + page: PluginsPage | undefined, +): "pending" | "render" | "redirect" { + if (!page) return "pending"; + return pluginsProvider(page) === routeProvider ? "render" : "redirect"; +} diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index b687baaa5..ca17e1523 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -58,7 +58,14 @@ import { type PluginServer, personalConnections, pluginsPageQueryOptions, + pluginsProvider, } from "@/lib/plugins/queries"; +import { + brokeredConnectionDescription, + brokeredDisconnectNote, + brokeredProvenance, + brokeredReach, +} from "@/lib/plugins/provider-vocabulary"; import { botSharedAppsQueryOptions, describeApproval, @@ -161,7 +168,7 @@ export function connectionKindFor( server: PluginServer | undefined, auth: CatalogueItem["auth"] | undefined, ): CatalogueItem["auth"] | "brokered" { - if (server?.provenance === "composio") return "brokered"; + if (server && brokeredProvenance(server.provenance)) return "brokered"; return auth ?? "deployment-bearer"; } @@ -202,6 +209,7 @@ function RouteComponent() { const { key } = useParams({ from: "/_authed/admin/plugins/$key" }); const queryClient = useQueryClient(); const plugins = useQuery(pluginsPageQueryOptions()); + const provider = pluginsProvider(plugins.data); /* * The administrator's OWN connections, not the deployment's. * @@ -577,7 +585,8 @@ function RouteComponent() { * nothing to connect at all — see `brokeredAccountsListedOn` — so there is no personal/ * shared choice to offer for it, and a row here could only open a dialog that refuses. */} - {server?.provenance === "composio" && + {server && + brokeredProvenance(server.provenance) && server.authScheme !== "NO_AUTH" ? ( <> @@ -630,7 +639,7 @@ function RouteComponent() { ? "Built into this deployment. There is no vendor to reach and no credential to hold — a call runs as whoever asked." : auth === "brokered" ? /* The row below says how, at length. This says whose account it is, which is the part that decides what an administrator has to do here — which is nothing. */ - "Reached through Composio, which holds each person's own account." + brokeredConnectionDescription(provider) : "What this deployment presents to the vendor. One credential, used for everybody." } title="Connection" @@ -690,16 +699,12 @@ function RouteComponent() { How this is reached - This app is reached through Composio, which holds the - account. This deployment sends one key and the name of - whoever is asking, so each person connects their own and a - Bot sees only what that person can see. There is no token to - paste. + {brokeredReach(provider).description} - Through Composio + {brokeredReach(provider).badge} @@ -820,7 +825,7 @@ function RouteComponent() { /* Said beside the button rather than after it: disconnecting ends the account at Composio, so what it undoes is not the row here but the grant on this person's mailbox, and connecting again is a fresh consent. */ - connectedDescription={`Connected, so a Bot granted these tools uses your ${title} as you. Disconnecting ends the account at Composio, not just here.`} + connectedDescription={`Connected, so a Bot granted these tools uses your ${title} as you. ${brokeredDisconnectNote(provider)}`} disconnectedDescription={`Connect your own account to try this connector. ${reassurance}`} /* The half of the line above that is true whichever way this app connects. An app whose key somebody types gets a different first sentence from the diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx index 98248531c..d3dd5c8cd 100644 --- a/app/src/routes/_authed/admin/plugins/composio.tsx +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -1,6 +1,6 @@ import { IconPlug } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Navigate } from "@tanstack/react-router"; import * as React from "react"; import { PageEmpty, @@ -23,9 +23,11 @@ import { enableComposioAppMutationOptions, invalidatePlugins, } from "@/lib/plugins/mutations"; +import { directoryRouteDecision } from "@/lib/plugins/provider-vocabulary"; import { type ComposioApp, composioAppsQueryOptions, + pluginsPageQueryOptions, } from "@/lib/plugins/queries"; import { queryClient } from "@/query-client"; @@ -61,7 +63,22 @@ export function matchingApps(apps: ComposioApp[]): ComposioApp[] { return [...apps].sort((left, right) => left.name.localeCompare(right.name)); } +/** + * The route guard: this directory answers only on a Composio deployment. + * + * A route answering for the wrong provider is how a Composio-shaped enablement flow gets spent + * against an Arcade project. Nothing is drawn, and so no directory request is made, until the page + * has said which provider this is. + */ function RouteComponent() { + const plugins = useQuery(pluginsPageQueryOptions()); + const decision = directoryRouteDecision("composio", plugins.data); + if (decision === "redirect") return ; + if (decision === "pending") return null; + return ; +} + +function ComposioDirectory() { const [search, setSearch] = React.useState(""); /* * Debounced, as on People, and for a stronger reason: every distinct term is a cache key of its diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx index b90b6994b..5aa82f903 100644 --- a/app/src/routes/_authed/admin/plugins/index.tsx +++ b/app/src/routes/_authed/admin/plugins/index.tsx @@ -28,7 +28,12 @@ import { type PluginServer, personalConnections, pluginsPageQueryOptions, + pluginsProvider, } from "@/lib/plugins/queries"; +import { + moreAppsRow, + moreAppsSectionDescription, +} from "@/lib/plugins/provider-vocabulary"; /** * What this deployment can reach, as one list per state. @@ -96,6 +101,12 @@ function summaryFor( function RouteComponent() { const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); + // A NAME for the "More apps" row, which cannot be read off a row that may not exist yet. + const provider = pluginsProvider(plugins.data); + const moreApps = moreAppsRow( + provider, + plugins.data?.composioConfigured ?? false, + ); /* * PERSONAL ONLY: a Shared app's connection is the deployment's standing account, not something @@ -241,7 +252,7 @@ function RouteComponent() { @@ -252,7 +263,7 @@ function RouteComponent() { * administrator with nothing to search for, and nothing to tell them the handful of * connectors above is not the whole story. */} - {plugins.data?.composioConfigured ? ( + {moreApps.linked ? ( } @@ -262,11 +273,8 @@ function RouteComponent() { - Browse Composio - - A few hundred apps, reached as whoever is asking. Each - person connects their own account. - + {moreApps.title} + {moreApps.description} @@ -280,11 +288,8 @@ function RouteComponent() { - Composio - - Add your Composio key to enable a catalogue of tools. Set - COMPOSIO_API_KEY on this deployment. - + {moreApps.title} + {moreApps.description} )} diff --git a/app/tests/arcade-admin-plugins.test.tsx b/app/tests/arcade-admin-plugins.test.tsx new file mode 100644 index 000000000..79cfe814a --- /dev/null +++ b/app/tests/arcade-admin-plugins.test.tsx @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; +import { + brokeredConnectionDescription, + brokeredDisconnectNote, + brokeredProvenance, + brokeredReach, + directoryRouteDecision, + moreAppsRow, + moreAppsSectionDescription, + providerVocabulary, +} from "@/lib/plugins/provider-vocabulary"; +import type { PluginsPage } from "@/lib/plugins/queries"; + +/** + * The admin Plugins screens carry the ACTIVE provider's vocabulary only (spec D11). Everything here + * is a pure function, so nothing renders. + */ + +const page = (provider?: "composio" | "arcade") => + ({ provider }) as PluginsPage; + +test("an Arcade deployment names Arcade and carries nothing Composio-specific", () => { + const everything = JSON.stringify([ + providerVocabulary("arcade"), + moreAppsRow("arcade", true), + moreAppsRow("arcade", false), + moreAppsSectionDescription("arcade"), + brokeredConnectionDescription("arcade"), + brokeredReach("arcade"), + brokeredDisconnectNote("arcade"), + ]); + expect(everything).not.toMatch(/composio/i); + expect(providerVocabulary("arcade").envVar).toBe("ARCADE_API_KEY"); + expect(providerVocabulary("arcade").directoryPath).toBe( + "/admin/plugins/arcade", + ); + expect(brokeredProvenance("arcade")).toBe(true); +}); + +test("a Composio deployment keeps today's exact wording", () => { + expect(providerVocabulary("composio")).toEqual({ + name: "Composio", + directoryLabel: "Browse Composio", + envVar: "COMPOSIO_API_KEY", + directoryPath: "/admin/plugins/composio", + }); + expect(moreAppsSectionDescription("composio")).toBe( + "A directory this deployment has not reviewed, reached through Composio.", + ); + expect(moreAppsRow("composio", true)).toEqual({ + title: "Browse Composio", + description: + "A few hundred apps, reached as whoever is asking. Each person connects their own account.", + linked: true, + }); + expect(moreAppsRow("composio", false)).toEqual({ + title: "Composio", + description: + "Add your Composio key to enable a catalogue of tools. Set COMPOSIO_API_KEY on this deployment.", + linked: false, + }); + expect(brokeredConnectionDescription("composio")).toBe( + "Reached through Composio, which holds each person's own account.", + ); + expect(brokeredReach("composio")).toEqual({ + description: + "This app is reached through Composio, which holds the account. This deployment sends one key and the name of whoever is asking, so each person connects their own and a Bot sees only what that person can see. There is no token to paste.", + badge: "Through Composio", + }); + expect(brokeredDisconnectNote("composio")).toBe( + "Disconnecting ends the account at Composio, not just here.", + ); +}); + +test("the vocabulary follows the provider rather than being hard-coded", () => { + expect(moreAppsRow("arcade", false).title).toBe("Arcade"); + expect(moreAppsRow("arcade", false).description).toContain("ARCADE_API_KEY"); + expect(brokeredReach("arcade").badge).toBe("Through Arcade"); + expect(brokeredDisconnectNote("arcade")).toContain("Arcade"); + expect(moreAppsRow("arcade", true).linked).toBe(false); +}); + +test("brokered-ness is read off the row and needs no provider", () => { + expect(brokeredProvenance("composio")).toBe(true); + expect(brokeredProvenance("arcade")).toBe(true); + expect(brokeredProvenance("custom")).toBe(false); + expect(brokeredProvenance("first-party")).toBe(false); +}); + +test("the Composio directory route does not answer on an Arcade deployment", () => { + expect(directoryRouteDecision("composio", page("arcade"))).toBe("redirect"); + expect(directoryRouteDecision("arcade", page("composio"))).toBe("redirect"); + expect(directoryRouteDecision("composio", page("composio"))).toBe("render"); + // An older server that did not say is Composio. + expect(directoryRouteDecision("composio", page(undefined))).toBe("render"); + // Not answering before the page says which provider this is. + expect(directoryRouteDecision("composio", undefined)).toBe("pending"); +}); From f205b632bad11334dd8f5e3724a28c39432acde9 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:45:27 +0200 Subject: [PATCH 061/232] feat(arcade): add GET /arcade/verify, the return leg's refusal half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where a person's browser lands after a brokered per-action consent. It breaks this file's JSON/401 convention deliberately: the caller is a browser on a top-level navigation from another company's screen, so an arrival with no session is sent to sign-in with a 303 carrying its flow_id rather than shown a raw error object. `requireUser` is therefore run by hand — the same guard, with only its refusal translated — the way `admin/gate.ts` already composes it. What this half does, writing nothing on any path: the capability gate (a presence test on the broker's declared `authorizations`, never a vendor-name comparison); a trimmed handle, refused without the vendor being asked; the attempt loaded from the SIGNED-IN ACTOR and never from the handle, refused when absent, past its TTL or holding a null auth_id; and the binding through `confirmReturn`, refused on a mismatch and on a confirmed id that is not the one this deployment minted — with the attempt row left untouched, because a late callback must never consume or alter the replacement it arrived behind. STEPS 4-7 ARE DELIBERATELY ABSENT and belong to the completing half: the bounded `auth.status` wait, the identity check, the D28 freshness re-resolution, the completion transaction and the 303 landing. Nothing here polls an authorization, completes an attempt or writes a connection row; a return leg that binds correctly is answered 503 until that half lands. The mismatch branch is assumption-backed and mocked-only until a live probe runs: the SDK declares no error shape for it, so `confirmReturn` rethrows if the vendor's real shape differs and the route surfaces an outage instead of a clean refusal. Nothing is written either way, and the auth_id comparison — measured identical through a completed consent — independently covers a confirm that succeeds against the wrong identity. Tests drive through `createApp`, because a route built but not mounted answers 404 in production while its own factory test passes. Every refusal queries `brokered_connections` and the `mcp.account_connected` audit entry rather than inferring "nothing was written" from a status code, and one test pins the `SameSite=Lax` cookie policy this route depends on — `Strict` would break this leg and nothing else would fail. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 207 +++++++++++++++++ server/tests/arcade-verify.test.ts | 357 +++++++++++++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 server/tests/arcade-verify.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 8478ea802..961ff6b96 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -119,6 +119,26 @@ function brokerRefusal( const DIRECTORY_UNAVAILABLE = "Composio would not answer with its app directory, and said nothing about why. Check that COMPOSIO_API_KEY is this project's key, and check Composio's status if it persists."; +/** + * Where a browser with no session is sent when it lands on the verifier, carrying what it arrived + * with. + * + * ABSOLUTE ON THE APP'S ORIGIN, for the reason {@link connectedAccountsUrlFor} is: the verifier + * lands on the API and the sign-in screen is a page, and locally those are two different ports — a + * relative `Location` would resolve against this server, which serves no pages at all. A deployment + * that serves both from one origin has no `appUrl`, and relative is correct there. + * + * THE HANDLE TRAVELS AS ITSELF AND NOT AS A RETURN ADDRESS. A `next=` parameter holding a URL is a + * redirect target somebody else gets a say in; this carries the one opaque value the return leg + * arrived with, so signing back in as the same person has everything it needs to resume and nothing + * it could be pointed elsewhere with. Encoded, because it is somebody else's id and this deployment + * does not know what is in it. + */ +function signInUrlFor(appUrl: string | undefined, flowId: string): string { + const base = `${appUrl?.replace(/\/+$/, "") ?? ""}/sign`; + return flowId ? `${base}?flow_id=${encodeURIComponent(flowId)}` : base; +} + /** * The Plugins surface: what this deployment has added, and which Bots may use it. * @@ -2430,6 +2450,193 @@ export function createPluginRoutes( return context.json({ cancelled }); }); + /** + * Where a person's BROWSER lands after a brokered per-action consent: the verifier. + * + * THIS ROUTE BREAKS THIS FILE'S JSON/401 CONVENTION DELIBERATELY, AND THE REASON IS WRITTEN HERE + * SO NOBODY "FIXES" IT TO MATCH ITS NEIGHBOURS. Every other route in this file answers `fetch` + * from OpenBot's own pages, so JSON is what the caller can read and 401 is what an unauthenticated + * one deserves. Here the caller is a PERSON'S BROWSER, arriving on a top-level navigation from the + * broker's own screen with a just-completed consent behind it — so an unauthenticated arrival is + * sent to sign-in with a 303 and a `Location` CARRYING ITS `flow_id`, never answered 401. A 401 + * here is somebody who has just granted a third party access to their mailbox being shown a raw + * error object on an API origin that serves no pages. + * + * WHICH IS WHY `requireUser` IS RUN BY HAND RATHER THAN MOUNTED IN FRONT OF THE HANDLER. The guard + * answers 401 JSON, and the whole point of this route is that it must not. It is still the same + * guard — the session is read, the role is resolved and `actor` is set by it, so nothing here + * re-implements authentication — and only its REFUSAL is translated into the redirect. The + * composition is the one `admin/gate.ts` already uses: call it with a `next` that records that it + * ran. + * + * SIGNING BACK IN AS THE SAME PERSON THEN COMPLETES NORMALLY, because the attempt is keyed on the + * signed-in person. Signing in as somebody else finds no attempt and refuses, which is correct and + * occasionally annoying. + * + * THIS HALF REFUSES AND NOTHING MORE. The bounded `auth.status` wait, the identity check, the + * freshness re-resolution, the completion transaction and the 303 landing are a separate step and + * are deliberately absent: nothing below polls an authorization, completes an attempt or writes a + * connection row. Every exit from this handler leaves the database exactly as it found it. + */ + routes.get("/arcade/verify", async (context) => { + /* + * Read, not yet judged. The redirect below has to carry it, and that redirect happens before + * the value is validated — reading a query string calls nobody and writes nothing. + */ + const flowId = (context.req.query("flow_id") ?? "").trim(); + + /* + * THE CAPABILITY GATE, AND IT IS A PRESENCE TEST ON A VALUE THE BROKER DECLARED. Never a + * `provider === "arcade"` comparison: see {@link ConnectedAppBroker.authorizations}, and + * `store.ts`'s gate comment, which records a defect where a gate and its exemption keyed on + * different things and the exemption failed OPEN. A deployment whose broker runs no correlated + * authorization flow has no return leg for this route to verify. + */ + if (!composio) { + return context.json( + { error: new BrokerUnconfiguredError().message }, + 503, + ); + } + const flow = composio.broker.authorizations; + if (!flow) { + return context.json( + { + error: + "This deployment's apps are connected once for the whole app, with the Connect button on the app's page; they are not authorized one action at a time.", + }, + 503, + ); + } + + let signedIn = false; + await requireUser(context, async () => { + signedIn = true; + }); + if (!signedIn) { + return context.redirect(signInUrlFor(connect?.appUrl, flowId), 303); + } + + /* + * A HANDLE THIS DEPLOYMENT CANNOT USE IS REFUSED WITHOUT ASKING THE VENDOR ANYTHING. Empty, + * missing or whitespace-only are one fact — the browser arrived without the one thing the + * return leg carries — and spending a vendor call on it would be an unauthenticated-shaped + * request reaching another company's API. + */ + if (!flowId) { + return context.json( + { + error: + "That link is missing the part that says which authorization it finishes, so nothing was done. Start the authorization again from the app's page.", + }, + 400, + ); + } + + /* + * THE ATTEMPT COMES FROM THE SIGNED-IN ACTOR AND NEVER FROM THE HANDLE. The vendor's return-leg + * id and the id its mint answered with are different id spaces with no endpoint between them, + * and nothing here may try to bridge them; trying identities until one is accepted would be an + * oracle reporting which of this deployment's identities have flows outstanding. The session + * names exactly one candidate, which is what makes the correlation exact rather than probable. + * + * {@link PluginStore.authorizationAttemptFor} already excludes a completed attempt and one past + * the fifteen-minute TTL, so all three absences arrive here as `null` and are refused in one + * sentence. A NULL `auth_id` is the fourth: its mint never completed, so there is nothing for + * the binding below to be checked against and the attempt can only ever expire. + */ + const actorId = context.var.actor.id; + const attempt = await store.authorizationAttemptFor(actorId); + if (!attempt || attempt.authId === null) { + return context.json( + { + error: + "There is no authorization in progress for you to finish — it may have expired, or it may have been started by somebody else or in another session. Start it again from the app's page.", + }, + 400, + ); + } + + /* + * THE BINDING. `handle` IS AN OPAQUE STRING THE SEAM DOES NOT INTERPRET — the vendor's word for + * it stops at the adapter, and nothing here parses it, compares it to a scheme or reads a + * provider out of it. + * + * THE MISMATCH BRANCH IS ASSUMPTION-BACKED AND IS MOCKED-ONLY UNTIL A LIVE PROBE RUNS. The SDK + * declares no error shape for a handle belonging to somebody else; the adapter detects one by + * normalizing the thrown error and reading `user_mismatch`, and RETHROWS anything else. If the + * vendor's real shape differs, `confirmReturn` does not quietly answer `{ mismatch: true }` — + * it throws, and the branch below surfaces an outage instead of a clean refusal. That is UX + * degradation and not a hole: nothing is written and the attempt row is left alone either way, + * and the `auth_id` comparison underneath independently covers a `confirm` that SUCCEEDS + * against the wrong identity. Do not read a passing mocked test as proof this path works. + */ + let confirmed: Awaited>; + try { + confirmed = await flow.confirmReturn({ + handle: flowId, + vendorUserId: attempt.vendorUserId, + }); + } catch { + /* + * NOTHING FROM THE VENDOR'S ANSWER TRAVELS WITH THIS — not its sentence, not its body, not + * the error. A reply on this leg can carry an authorization url, which is a bearer capability, + * and an outage is not worth a person's consent being quotable out of an error body. + */ + return context.json( + { + error: + "The app's vendor did not answer when this deployment tried to finish that authorization, so nothing was recorded. Try the link again in a moment.", + }, + 502, + ); + } + + /* + * TWO REFUSALS, ONE SENTENCE, AND THE ATTEMPT ROW IS LEFT UNTOUCHED BY BOTH. A handle the vendor + * says belongs to somebody else, and a confirmation naming a DIFFERENT authorization than the + * one this deployment minted, are both "this return leg is not for the attempt you are holding". + * + * THE `auth_id` COMPARISON IS NOT OPTIONAL AND IT IS NOT HOPEFUL: the confirmation's id was + * measured IDENTICAL to the mint's through a completed consent, so this is an exact equality + * over one id space. Identity alone is not correlation — an expired read attempt, a fresh send + * attempt under the same identity, and then the read's callback arriving late matches on + * identity at every step, and a check stopping there would attribute a read consent to a send + * request. Local expiry does not cancel a vendor flow, so late callbacks are ordinary. + * + * NOTHING IS WRITTEN AND NOTHING IS CLOSED. A late callback must never consume or ALTER the + * replacement it arrived behind, so the row is not deleted, not completed and not marked. + */ + if ( + "mismatch" in confirmed || + confirmed.authorizationId !== attempt.authId + ) { + return context.json( + { + error: + "That link does not finish the authorization you have in progress, so nothing was recorded and it is still waiting. Finish it from the tab it was started in, or start it again from the app's page.", + }, + 400, + ); + } + + /* + * EVERYTHING PAST THE BINDING BELONGS TO THE OTHER HALF OF THIS VERIFIER: the bounded wait on + * the authorization's status, the identity check against it, the freshness re-resolution, the + * one transaction that writes the connection row and its audit entry, and the 303 that lands the + * person back on the page they left from. Until that half lands, a return leg that binds + * correctly is answered as a capability this deployment does not yet have — which keeps the + * promise this half makes: NOTHING IS WRITTEN HERE, on any path. + */ + return context.json( + { + error: + "This deployment cannot finish that authorization yet, so nothing was recorded. The authorization is still waiting, and nothing at the app's vendor was changed.", + }, + 503, + ); + }); + /** * Where the vendor sends somebody back. * diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts new file mode 100644 index 000000000..243491104 --- /dev/null +++ b/server/tests/arcade-verify.test.ts @@ -0,0 +1,357 @@ +// server/tests/arcade-verify.test.ts +/** + * The verifier's REFUSAL half: a browser coming back from a brokered per-action consent that this + * deployment will not complete. + * + * ALMOST EVERY ASSERTION HERE IS "NOTHING WAS WRITTEN", which is the shape where a broken + * implementation looks exactly like a passing one. So no case infers that from a status code: + * each one QUERIES `brokered_connections` for a row belonging to the actor, and `audit_events` for + * the `mcp.account_connected` entry the completion transaction files, and requires both to be + * empty. The actor ids are suite-scoped, so a count of zero is a fact about this run. + * + * AND IT DRIVES THROUGH `createApp`, NOT THROUGH `createPluginRoutes`. A route that is built but + * never mounted answers 404 in production while its own factory test passes — the failure every + * Shared-app surface had before `shared-wiring.integration.test.ts` was written. + */ +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { getCookies } from "better-auth/cookies"; +import { and, eq } from "drizzle-orm"; +import { createApp } from "../src/app"; +import { createAuditStore } from "../src/audit"; +import { createAuth } from "../src/auth/index"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { + auditEvents, + brokeredConnections, + mcpServers, + pendingAuthorizations, +} from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const APP = `arcade-verify-${suite}`; +const ACTOR = `verifier-${suite}`; +const STRANGER = `stranger-${suite}`; +const ACTION = "Gmail.ListEmails"; +const MINTED = "ar_minted_for_this_attempt"; +const APP_URL = "https://app.example"; + +/** Every call this suite lets reach the seam. A refusal that spends one is a refusal too late. */ +let confirmCalls: { handle: string; vendorUserId: string }[] = []; +/** What the next `confirmReturn` answers, or throws. Set per test. */ +let confirmAnswer: () => Promise< + { authorizationId: string } | { mismatch: true } +> = async () => ({ authorizationId: MINTED }); + +/** + * A broker that DECLARES the correlated authorization flow, which is the only thing this route's + * gate reads. Every member is declared because the seam requires a broker to SAY what it runs; see + * `ConnectedAppBroker.authorizations` and `.grantIsLocal`. + */ +const broker: ConnectedAppBroker = { + grantIsLocal: false, + authorizations: { + status: async () => { + throw new Error("the refusal half polls no authorization"); + }, + confirmReturn: async (query) => { + confirmCalls.push(query); + return confirmAnswer(); + }, + }, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent?secret=bearer-capability", + authorizationId: MINTED, + }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => null, +}; + +/** + * The deployment as `createApp` builds it, with a session for `who` or none at all. + * + * Positional, by index rather than by a run of `undefined`s, for the reason + * `shared-wiring.integration.test.ts` gives: a parameter added in the middle then moves a type error + * onto the wrong index here rather than shifting an argument silently. + */ +function build(who: string | null) { + const auditStore = createAuditStore(database); + const pluginStore = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + const args = new Array(createApp.length) as unknown as Parameters< + typeof createApp + >; + args[0] = loadConfig(testEnvironment({ OPENBOT_APP_URL: APP_URL })); + args[1] = { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => + who === null + ? null + : { user: { id: who, email: `${who}@example.test`, name: "Person" } }, + }, + } as never; + args[2] = { rolesForUser: async () => ["user"] } as never; + args[12] = auditStore; + args[14] = pluginStore; + args[29] = { broker, provider: "arcade" }; + return createApp(...args); +} + +function verify(who: string | null, query: string) { + return build(who).request( + `http://openbot.test/api/plugins/arcade/verify${query}`, + ); +} + +/** What the completion transaction would have left behind. Queried, never inferred from a status. */ +async function written(actorId: string) { + const connections = await database + .select() + .from(brokeredConnections) + .where(eq(brokeredConnections.userId, actorId)); + const events = await database + .select() + .from(auditEvents) + .where( + and( + eq(auditEvents.actorUserId, actorId), + eq(auditEvents.eventType, "mcp.account_connected"), + ), + ); + return { connections: connections.length, successEvents: events.length }; +} + +async function expectNothingWritten() { + expect(await written(ACTOR)).toEqual({ + connections: 0, + successEvents: 0, + }); + expect(await written(STRANGER)).toEqual({ + connections: 0, + successEvents: 0, + }); +} + +/** + * One live attempt for `ACTOR`, written directly so its age and its `auth_id` are the test's to + * choose. `createdAt` is a JS `Date`, which carries only milliseconds — the same precision + * `claimAuthorizationAttempt` truncates the column to — so a value read back out compares exactly. + */ +async function giveAttempt(options: { + authId: string | null; + ageMinutes: number; +}) { + const createdAt = new Date( + Math.floor(Date.now() / 1000) * 1000 - options.ageMinutes * 60_000, + ); + await database.insert(pendingAuthorizations).values({ + actorId: ACTOR, + serverId: APP, + action: ACTION, + holder: "person", + userId: ACTOR, + vendorUserId: ACTOR, + authId: options.authId, + createdAt, + }); + return createdAt; +} + +beforeEach(async () => { + confirmCalls = []; + confirmAnswer = async () => ({ authorizationId: MINTED }); + await database + .delete(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, ACTOR)); + await database.delete(mcpServers).where(eq(mcpServers.id, APP)); + await database.insert(mcpServers).values({ + id: APP, + title: "Gmail", + vendor: "Arcade", + url: `arcade://Gmail-${suite}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "personal", + }); +}); + +afterAll(async () => { + await database + .delete(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, ACTOR)); + await database.delete(mcpServers).where(eq(mcpServers.id, APP)); +}); + +describe("a return leg this deployment will not complete", () => { + test("an empty flow_id is refused without the vendor being asked", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(ACTOR, "?flow_id="); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + test("a missing flow_id is refused without the vendor being asked", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(ACTOR, ""); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + test("a whitespace-only flow_id is refused without the vendor being asked", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(ACTOR, "?flow_id=%20%20%09"); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + test("no attempt at all is refused", async () => { + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + test("an attempt past the fifteen-minute TTL is refused", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 20 }); + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + test("an attempt whose mint never answered an id is refused", async () => { + await giveAttempt({ authId: null, ageMinutes: 1 }); + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + /** + * THE LATE CALLBACK. The vendor confirms, honestly, an authorization that is not the one this + * deployment is holding — the read attempt that expired arriving behind the send attempt that + * replaced it. The replacement must be left EXACTLY as it was, so the row itself is read back + * rather than the status code being trusted to imply it. + */ + test("a confirmation naming a different attempt refuses and leaves the row untouched", async () => { + const createdAt = await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + confirmAnswer = async () => ({ authorizationId: "ar_some_other_attempt" }); + + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([{ handle: "flow-abc", vendorUserId: ACTOR }]); + + const [row] = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, ACTOR)); + expect(row?.completedAt).toBeNull(); + expect(row?.outcome).toBeNull(); + expect(row?.authId).toBe(MINTED); + expect(row?.createdAt.getTime()).toBe(createdAt.getTime()); + await expectNothingWritten(); + }); + + /** + * The vendor saying the handle is somebody else's. MOCKED-ONLY: the SDK declares no error shape + * for this, so the adapter's detection is an assumption until a live probe runs. A passing test + * here is not proof the path works — what it pins is that the route refuses cleanly and writes + * nothing when the seam DOES answer this way. + */ + test("a handle the vendor says is somebody else's refuses", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + confirmAnswer = async () => ({ mismatch: true }); + + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.status).toBe(400); + + const [row] = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, ACTOR)); + expect(row?.completedAt).toBeNull(); + expect(row?.outcome).toBeNull(); + expect(row?.authId).toBe(MINTED); + await expectNothingWritten(); + }); + + /** + * Somebody else's browser, or the same person signed in as somebody else. The attempt is keyed on + * the signed-in person and is never found from the handle, so there is nothing to correlate to. + */ + test("coming back signed in as a different person refuses", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(STRANGER, "?flow_id=flow-abc"); + expect(response.status).toBe(400); + expect(confirmCalls).toEqual([]); + + const [row] = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, ACTOR)); + expect(row?.completedAt).toBeNull(); + expect(row?.authId).toBe(MINTED); + await expectNothingWritten(); + }); +}); + +describe("a return leg with no session", () => { + /** + * NOT 401 JSON. The caller is a person's browser on a top-level navigation with a just-completed + * consent behind it, and the handle has to survive the trip so signing back in as the same person + * can resume. + */ + test("is sent to sign-in with its flow_id, not answered with an error object", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(null, "?flow_id=flow%2Fabc"); + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe( + `${APP_URL}/sign?flow_id=flow%2Fabc`, + ); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); +}); + +/** + * THE COOKIE POLICY THIS ROUTE DEPENDS ON, AND NOTHING ELSE IN THE SUITE WOULD REPORT IT. + * + * `SameSite=Lax` is the one value that lets the session cookie travel on a top-level cross-site GET + * navigation, which is exactly the leg a person makes coming back from the broker's consent screen. + * `Strict` withholds it there and nowhere else: the verifier would find no session and send + * everybody to sign-in, every other route would keep working, and no other test would fail. So the + * policy is asserted through the deployment's OWN auth configuration — `getCookies` is what Better + * Auth itself uses to build the attributes, so an override added under `advanced` is read here the + * same way the browser would read it. + */ +test("the session cookie is SameSite=Lax, which is what lets it survive the return leg", () => { + const config = loadConfig(testEnvironment({ OPENBOT_APP_URL: APP_URL })); + const auth = createAuth(config, database); + expect(getCookies(auth.options).sessionToken.attributes.sameSite).toBe("lax"); +}); From 1ea9100536e08ce2a21fe31ca8dd8fd819aa1582 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:46:44 +0200 Subject: [PATCH 062/232] feat(arcade): warn on the enablement screen when a toolkit classified nothing Driven by the measured classifiedCount vs actionCount; null (unmeasured) says nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/_authed/admin/plugins/composio.tsx | 37 +++++++ app/tests/arcade-coverage.test.tsx | 98 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 app/tests/arcade-coverage.test.tsx diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx index d3dd5c8cd..53e25508b 100644 --- a/app/src/routes/_authed/admin/plugins/composio.tsx +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -47,6 +47,42 @@ export const Route = createFileRoute("/_authed/admin/plugins/composio")({ component: RouteComponent, }); +/** + * The D8 coverage warning for an app, or null when there is nothing to say. + * + * Driven by `classifiedCount` against `actionCount` and nothing else. `classifiedCount: 0` is + * MEASURED, nothing classified, so it warns; `null` is NOT MEASURED (a Composio app) so it says + * nothing. Those are opposite sentences, and `?? 0` would collapse one into the other. + * + * The classification is measured server-side from the fetched listing and never re-derived here: + * never inferred from the `Api` suffix (Arcade's naming convention, not a contract — a guess wearing + * a measurement's clothes), and never from `classify()`'s verdict, which is TOTAL: its closed + * destructive-write verdict for absent metadata is byte-identical to its verdict for a genuinely + * destructive action, so counting "did classify return something" counts nothing. + * + * Phrased as what it will feel like, not as a percentage. + */ +export function coverageWarning( + app: Pick, +): string | null { + if (app.classifiedCount === null) return null; + if (app.actionCount > 0 && app.classifiedCount === 0) { + return "This provider has not said which of its actions only read, so every action is treated as a destructive write and every call will ask for approval."; + } + return null; +} + +/** The warning on a row, shown before Add is pressed. Draws nothing when there is none. */ +export function CoverageNote({ app }: { app: ComposioApp }) { + const warning = coverageWarning(app); + if (!warning) return null; + return ( +

+ {warning} +

+ ); +} + /** * The apps to draw, in the order to draw them. * @@ -207,6 +243,7 @@ function ComposioDirectory() { {entry.name} {entry.description} + {entry.enabled ? null : } {/* The size of the decision, stated before it is made: an app is not one diff --git a/app/tests/arcade-coverage.test.tsx b/app/tests/arcade-coverage.test.tsx new file mode 100644 index 000000000..d72c1a544 --- /dev/null +++ b/app/tests/arcade-coverage.test.tsx @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import fixture from "../../server/tests/fixtures/arcade-catalogue.json"; +import type { ComposioApp } from "@/lib/plugins/queries"; +import { + CoverageNote, + coverageWarning, +} from "@/routes/_authed/admin/plugins/composio"; + +/** + * The D8 coverage warning on the enablement screen. Driven by `classifiedCount` against + * `actionCount` and nothing else: `0` is measured-and-none (warn), `null` is not measured (silent). + */ + +type FixtureTool = { + toolkit: { name: string }; + metadata?: { behavior?: unknown }; +}; + +/** + * The counts for a toolkit as the SERVER would have measured them. The fixture is evidence of what + * Arcade returns; this folds it the way `listApps` does (a tool is classified when it carries + * behavior metadata), so the app under test is built from real tools, not a literal. + */ +function fromFixture(toolkit: string): ComposioApp { + const tools = (Object.values(fixture.tools) as FixtureTool[]).filter( + (tool) => tool.toolkit.name === toolkit, + ); + return app({ + slug: toolkit.toLowerCase(), + name: toolkit, + actionCount: tools.length, + classifiedCount: tools.filter((tool) => tool.metadata?.behavior).length, + }); +} + +function app(overrides: Partial & { slug: string }): ComposioApp { + return { + name: "App", + description: "", + logo: null, + categories: [], + actionCount: 1, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, + enabled: false, + ...overrides, + }; +} + +test("the curated toolkit Apollo (1/1 classified) shows no warning", () => { + const apollo = fromFixture("Apollo"); + expect([apollo.classifiedCount, apollo.actionCount]).toEqual([1, 1]); + expect(coverageWarning(apollo)).toBeNull(); + expect(renderToStaticMarkup()).toBe(""); +}); + +test("the generated toolkit AirtableApi (0/2 classified) renders the warning on its row", () => { + const airtable = fromFixture("AirtableApi"); + expect([airtable.classifiedCount, airtable.actionCount]).toEqual([0, 2]); + expect(coverageWarning(airtable)).toMatch(/destructive write/); + expect(renderToStaticMarkup()).toContain( + "approval", + ); + // Phrased as what it feels like, never as a percentage. + expect(coverageWarning(airtable)).not.toMatch(/%|\d/); +}); + +test("a 0-classified toolkit renamed WITHOUT the Api suffix still warns", () => { + const renamed = { ...fromFixture("AirtableApi"), name: "Airtable" }; + expect(coverageWarning(renamed)).not.toBeNull(); +}); + +test("a classified toolkit WITH the Api suffix does not warn", () => { + expect( + coverageWarning( + app({ + slug: "x", + name: "DatadogApi", + actionCount: 5, + classifiedCount: 5, + }), + ), + ).toBeNull(); +}); + +test("classifiedCount null draws nothing", () => { + const unmeasured = app({ slug: "s", name: "SlackApi", actionCount: 40 }); + expect(coverageWarning(unmeasured)).toBeNull(); + expect(renderToStaticMarkup()).toBe(""); +}); + +test("a measured zero over no actions is not a warning", () => { + expect( + coverageWarning(app({ slug: "e", actionCount: 0, classifiedCount: 0 })), + ).toBeNull(); +}); From 941377cdcb65114fc194a46ad4f9e3d653bad64d Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 15:50:31 +0200 Subject: [PATCH 063/232] feat(arcade): state consent breadth on the enablement screen before Add Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/_authed/admin/plugins/composio.tsx | 48 ++++++ app/tests/arcade-breadth.test.tsx | 140 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 app/tests/arcade-breadth.test.tsx diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx index 53e25508b..c7605dc11 100644 --- a/app/src/routes/_authed/admin/plugins/composio.tsx +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -83,6 +83,53 @@ export function CoverageNote({ app }: { app: ComposioApp }) { ); } +/** + * The D32 consent-breadth line for an app, or null when there is nothing to say. + * + * Read off the counts the server measured from the same listing, never recomputed here. `null` is + * NOT MEASURED and draws nothing; a measured zero is a different sentence, so no count is ever + * defaulted with `?? 0`. + * + * An action with no scopes is not its own permission: the connection itself is the grant, so any + * approval covers it. That is why a mixed app says how many actions need no permission of their own, + * rather than letting one permission over two actions read as "one permission covering both". + * + * It states breadth and never confinement. Whether one approval stays inside the action that asked + * for it is the provider's property, measured per app, so nothing here promises it. + */ +export function consentBreadth( + app: Pick< + ComposioApp, + "actionCount" | "permissionCount" | "scopedActionCount" + >, +): string | null { + const { actionCount, permissionCount, scopedActionCount } = app; + if (permissionCount === null || scopedActionCount === null) return null; + if (actionCount <= 0) return null; + if (permissionCount === 0) { + return `Approving any action in this app approves all ${actionCount} of them.`; + } + const permissions = `${permissionCount} separate ${permissionCount === 1 ? "permission" : "permissions"}`; + const actions = `${actionCount} ${actionCount === 1 ? "action" : "actions"}`; + const base = `This app uses ${permissions} across ${actions}.`; + const unscoped = actionCount - scopedActionCount; + if (unscoped <= 0) return base; + return unscoped === 1 + ? `${base} A further 1 requests no permission of its own and becomes callable with any approval here.` + : `${base} A further ${unscoped} request no permission of their own and become callable with any approval here.`; +} + +/** The breadth line on a row, shown before Add is pressed. Draws nothing when there is none. */ +export function BreadthNote({ app }: { app: ComposioApp }) { + const breadth = consentBreadth(app); + if (!breadth) return null; + return ( +

+ {breadth} +

+ ); +} + /** * The apps to draw, in the order to draw them. * @@ -244,6 +291,7 @@ function ComposioDirectory() { {entry.name} {entry.description} {entry.enabled ? null : } + {entry.enabled ? null : } {/* The size of the decision, stated before it is made: an app is not one diff --git a/app/tests/arcade-breadth.test.tsx b/app/tests/arcade-breadth.test.tsx new file mode 100644 index 000000000..ad8b3e0a7 --- /dev/null +++ b/app/tests/arcade-breadth.test.tsx @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import fixture from "../../server/tests/fixtures/arcade-catalogue.json"; +import type { ComposioApp } from "@/lib/plugins/queries"; +import { + BreadthNote, + consentBreadth, +} from "@/routes/_authed/admin/plugins/composio"; + +/** + * The D32 consent-breadth line on the enablement screen. Derived from the three measured counts and + * nothing else; `null` is not-measured and draws nothing, which is the opposite of a measured zero. + */ + +type FixtureTool = { + toolkit: { name: string }; + requirements?: { authorization?: { oauth2?: { scopes?: string[] } } }; +}; + +/** The counts as the SERVER folds them (`listApps`): distinct non-empty sorted scope sets. */ +function fromFixture(toolkit: string): ComposioApp { + const tools = (Object.values(fixture.tools) as FixtureTool[]).filter( + (tool) => tool.toolkit.name === toolkit, + ); + const sets = tools + .map((tool) => tool.requirements?.authorization?.oauth2?.scopes ?? []) + .filter((scopes) => scopes.length > 0); + return app({ + name: toolkit, + actionCount: tools.length, + permissionCount: new Set(sets.map((s) => [...s].sort().join(" "))).size, + scopedActionCount: sets.length, + }); +} + +function app(overrides: Partial = {}): ComposioApp { + return { + slug: "app", + name: "App", + description: "", + logo: null, + categories: [], + actionCount: 1, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, + enabled: false, + ...overrides, + }; +} + +test("Apollo (no permissions) says approving any action approves all of them", () => { + const apollo = fromFixture("Apollo"); + expect([apollo.permissionCount, apollo.actionCount]).toEqual([0, 1]); + expect(consentBreadth(apollo)).toMatch(/approves/); +}); + +test("the plural wholly-unscoped case says all N of them", () => { + expect( + consentBreadth( + app({ actionCount: 43, permissionCount: 0, scopedActionCount: 0 }), + ), + ).toBe("Approving any action in this app approves all 43 of them."); +}); + +test("Asana and Attio are fully scoped: one permission across one action", () => { + for (const name of ["Asana", "Attio"]) { + const toolkit = fromFixture(name); + expect([ + toolkit.permissionCount, + toolkit.scopedActionCount, + toolkit.actionCount, + ]).toEqual([1, 1, 1]); + expect(consentBreadth(toolkit)).toBe( + "This app uses 1 separate permission across 1 action.", + ); + } +}); + +test("more than one permission, fully scoped", () => { + expect( + consentBreadth( + app({ actionCount: 30, permissionCount: 4, scopedActionCount: 30 }), + ), + ).toBe("This app uses 4 separate permissions across 30 actions."); +}); + +test("AirtableApi is the mixed shape: names the further K that need no permission", () => { + const airtable = fromFixture("AirtableApi"); + expect([ + airtable.permissionCount, + airtable.scopedActionCount, + airtable.actionCount, + ]).toEqual([1, 1, 2]); + expect(consentBreadth(airtable)).toBe( + "This app uses 1 separate permission across 2 actions. A further 1 requests no permission of its own and becomes callable with any approval here.", + ); +}); + +test("the mixed shape with several further actions", () => { + expect( + consentBreadth( + app({ actionCount: 10, permissionCount: 2, scopedActionCount: 6 }), + ), + ).toBe( + "This app uses 2 separate permissions across 10 actions. A further 4 request no permission of their own and become callable with any approval here.", + ); +}); + +test("any null count draws nothing, never a zero", () => { + const measured = { permissionCount: 2, scopedActionCount: 2, actionCount: 2 }; + for (const nulled of ["permissionCount", "scopedActionCount"] as const) { + const unmeasured = app({ ...measured, [nulled]: null }); + expect(consentBreadth(unmeasured)).toBeNull(); + expect(renderToStaticMarkup()).toBe(""); + } +}); + +test("it renders on the enablement step, before enabling", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-testid="breadth-note"'); + expect(html).toContain("1 separate permission across 2 actions"); +}); + +test("no copy promises confinement", () => { + const shapes = [ + app({ actionCount: 3, permissionCount: 0, scopedActionCount: 0 }), + app({ actionCount: 3, permissionCount: 2, scopedActionCount: 3 }), + app({ actionCount: 3, permissionCount: 2, scopedActionCount: 1 }), + ]; + for (const shape of shapes) { + expect(consentBreadth(shape)).not.toMatch( + /confin|only (the|that|this)|just (the|that|this)|limited to|isolat|least.privilege|restricted to|nothing else/i, + ); + } +}); From 58687490d3f1779976133b924de45ae1b782d312 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:00:54 +0200 Subject: [PATCH 064/232] fix(arcade): verify answers 403 for a removed role and gates after the session check A session with no role was redirected to sign-in, a dead end. Only a 401 now redirects; 403 says nothing was recorded. The capability gate moves behind the session check and is now tested (authorizations: null, no broker bag). Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 31 ++++++++++---- server/tests/arcade-verify.test.ts | 65 +++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 961ff6b96..c0954257e 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2485,6 +2485,29 @@ export function createPluginRoutes( */ const flowId = (context.req.query("flow_id") ?? "").trim(); + let signedIn = false; + const refusal = await requireUser(context, async () => { + signedIn = true; + }); + if (!signedIn) { + /* + * ONLY A MISSING SESSION GOES TO SIGN-IN. The guard also refuses 403 for a session with no + * role, and sending that person to sign-in is a dead end: the sign-in screen's own load hits + * the same guard, gets the same 403, and they sign in as themselves again with nothing + * changed and never told their access was removed. + */ + if (refusal instanceof Response && refusal.status === 401) { + return context.redirect(signInUrlFor(connect?.appUrl, flowId), 303); + } + return context.json( + { + error: + "This deployment no longer recognises you, so nothing was recorded. Ask an administrator to restore your access, then start the authorization again from the app's page.", + }, + 403, + ); + } + /* * THE CAPABILITY GATE, AND IT IS A PRESENCE TEST ON A VALUE THE BROKER DECLARED. Never a * `provider === "arcade"` comparison: see {@link ConnectedAppBroker.authorizations}, and @@ -2509,14 +2532,6 @@ export function createPluginRoutes( ); } - let signedIn = false; - await requireUser(context, async () => { - signedIn = true; - }); - if (!signedIn) { - return context.redirect(signInUrlFor(connect?.appUrl, flowId), 303); - } - /* * A HANDLE THIS DEPLOYMENT CANNOT USE IS REFUSED WITHOUT ASKING THE VENDOR ANYTHING. Empty, * missing or whitespace-only are one fact — the browser arrived without the one thing the diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 243491104..04fb94b87 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -88,7 +88,13 @@ const broker: ConnectedAppBroker = { * `shared-wiring.integration.test.ts` gives: a parameter added in the middle then moves a type error * onto the wrong index here rather than shifting an argument silently. */ -function build(who: string | null) { +function build( + who: string | null, + options: { + roles?: string[]; + brokerBag?: { broker: ConnectedAppBroker; provider: string } | undefined; + } = {}, +) { const auditStore = createAuditStore(database); const pluginStore = createPluginStore({ brokerProvider: "arcade", @@ -113,15 +119,20 @@ function build(who: string | null) { : { user: { id: who, email: `${who}@example.test`, name: "Person" } }, }, } as never; - args[2] = { rolesForUser: async () => ["user"] } as never; + args[2] = { rolesForUser: async () => options.roles ?? ["user"] } as never; args[12] = auditStore; args[14] = pluginStore; - args[29] = { broker, provider: "arcade" }; + args[29] = + "brokerBag" in options ? options.brokerBag : { broker, provider: "arcade" }; return createApp(...args); } -function verify(who: string | null, query: string) { - return build(who).request( +function verify( + who: string | null, + query: string, + options?: Parameters[1], +) { + return build(who, options).request( `http://openbot.test/api/plugins/arcade/verify${query}`, ); } @@ -339,6 +350,50 @@ describe("a return leg with no session", () => { }); }); +describe("a return leg with a session whose role was removed", () => { + test("is told so with a 403, not sent to a sign-in that cannot help", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(ACTOR, "?flow_id=flow-abc", { roles: [] }); + expect(response.status).not.toBe(303); + expect(response.status).toBe(403); + expect(response.headers.get("location")).toBeNull(); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); +}); + +describe("a deployment whose broker runs no correlated authorization flow", () => { + test("a broker declaring authorizations: null answers 503", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(ACTOR, "?flow_id=flow-abc", { + brokerBag: { + broker: { ...broker, authorizations: null }, + provider: "composio", + }, + }); + expect(response.status).toBe(503); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + test("no broker bag at all answers 503", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + const response = await verify(ACTOR, "?flow_id=flow-abc", { + brokerBag: undefined, + }); + expect(response.status).toBe(503); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); + + test("the gate does not answer before the session is checked", async () => { + const response = await verify(null, "?flow_id=flow-abc", { + brokerBag: undefined, + }); + expect(response.status).toBe(303); + }); +}); + /** * THE COOKIE POLICY THIS ROUTE DEPENDS ON, AND NOTHING ELSE IN THE SUITE WOULD REPORT IT. * From a8153025e78517d0c49576b775abc19d4854f3d6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:04:06 +0200 Subject: [PATCH 065/232] test(arcade): aim the newer-attempt discard at the timestamp the aged row held The test aged the row then discarded at the pre-age createdAt, which no row holds, so expect(false) passed whether or not a replacement stood. Read the aged row's created_at back and aim at that; the two values now differ by 16 minutes by construction, removing the same-millisecond flake. Co-Authored-By: Claude Opus 5 (1M context) --- .../arcade-attempt-store.integration.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index b9749e8cf..30c3e7d53 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -214,17 +214,26 @@ describe("discardAuthorizationAttempt", () => { const older = await claim(a, "older"); if (!older.claimed) throw new Error("claim failed"); await age(a, "16 minutes"); + // What the row NOW holds, which is what a late discard would have been aiming at. + // `older.attempt.createdAt` is the PRE-age value and matches nothing, so a discard + // aimed at it misses whether or not a replacement exists, which is no test at all. + const [aged] = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, a)); + if (!aged) throw new Error("aged row missing"); + const agedAt = aged.createdAt; + const newer = await claim(a, "newer"); expect(newer.claimed).toBe(true); if (!newer.claimed) return; - expect(newer.attempt.createdAt.getTime()).toBeGreaterThan( - older.attempt.createdAt.getTime(), - ); + // Differ by 16 minutes BY CONSTRUCTION, not by wall-clock luck: no same-millisecond race. + expect(newer.attempt.createdAt.getTime()).not.toBe(agedAt.getTime()); - // Aimed at the OLD attempt's created_at: it must not reach the new one. + // Aimed at the attempt that actually stood: the replacement must survive it. const deleted = await store.discardAuthorizationAttempt({ actorId: a, - createdAt: older.attempt.createdAt, + createdAt: agedAt, }); expect(deleted).toBe(false); const standing = await store.authorizationAttemptFor(a); From 80e15a26d659ec66b73d51487a0bee41ce3630d8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:05:35 +0200 Subject: [PATCH 066/232] test(arcade): guard the admin plugins pages' vocabulary call sites; neutral test id Nothing asserted that index.tsx and $key.tsx call the provider-vocabulary functions, only that the functions return the right strings. Add a source-level guard (text scan, comments stripped, imports nothing) that index.tsx and $key.tsx contain no capitalised "Composio" and that each page calls the vocabulary functions it should (composio.tsx is exempt from the literal rule: it is Composio-gated by construction, but still checked for directoryRouteDecision). Scans 3 files. Seen RED: a planted `const x = "Composio apps"` in index.tsx failed the test naming index.tsx:250; removed afterwards. Rename the "More apps" row's data-testid from plugin-composio to plugin-directory so no rendered markup on an Arcade deployment names Composio. No test referenced the old id (grep clean before and after). The hard-coded render={} is left alone: TanStack Router types `to` against the generated route tree, so directoryPath cannot typecheck until /admin/plugins/arcade is registered, a separate micro-task. Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/_authed/admin/plugins/index.tsx | 4 +- .../admin-plugins-vocabulary-wiring.test.ts | 84 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 app/tests/admin-plugins-vocabulary-wiring.test.ts diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx index 5aa82f903..f1939b3b2 100644 --- a/app/src/routes/_authed/admin/plugins/index.tsx +++ b/app/src/routes/_authed/admin/plugins/index.tsx @@ -265,7 +265,7 @@ function RouteComponent() { */} {moreApps.linked ? ( } size="sm" > @@ -283,7 +283,7 @@ function RouteComponent() { ) : ( /* No chevron and no link: the row goes nowhere, because there is nowhere to go until the key is set. */ - + diff --git a/app/tests/admin-plugins-vocabulary-wiring.test.ts b/app/tests/admin-plugins-vocabulary-wiring.test.ts new file mode 100644 index 000000000..fd20088b1 --- /dev/null +++ b/app/tests/admin-plugins-vocabulary-wiring.test.ts @@ -0,0 +1,84 @@ +// app/tests/admin-plugins-vocabulary-wiring.test.ts +/** + * Guard: the admin plugins pages CALL the provider vocabulary; they do not + * re-hardcode a vendor name. + * + * WHY: arcade-admin-plugins.test.tsx pins every string the vocabulary + * functions return, but nothing pinned that `index.tsx` and `$key.tsx` actually + * call them. A guard on return values without one on call sites reads as + * coverage while protecting nothing: a later edit can paste "Composio" straight + * back into the JSX, every vocabulary test stays green, and an Arcade + * deployment shows a Composio-named screen (spec D11: screens show the ACTIVE + * provider only; this PR changes no Composio behaviour). It is the same + * argument the server-side vendor-name guard makes about catching one spelling + * of a mistake and not the other. + * + * Reads the route sources as TEXT (like arcade-no-browser-calls.test.ts) and + * imports nothing from them. Comments are stripped first, so docblocks may + * still explain the vendor. Then: + * 1. `index.tsx` and `$key.tsx` contain no capitalised "Composio" (string + * literal, JSX text or test id). + * 2. each vocabulary function appears in the file that should call it. + * + * `composio.tsx` is exempt from (1): that route is Composio-gated by + * construction (directoryRouteDecision), so its literals are legitimate. It is + * still held to (2). + * + * If this goes red, route the string through provider-vocabulary.ts; do not + * loosen the guard. The scanned-count assertion stops it passing vacuously. + */ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const DIR = join(import.meta.dir, "../src/routes/_authed/admin/plugins"); + +function stripComments(src: string): string { + return src + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:"'`\w])\/\/.*$/gm, "$1"); +} + +function load(name: string): string { + return stripComments(readFileSync(join(DIR, name), "utf8")); +} + +const VOCAB_CALLERS: Record = { + "index.tsx": ["moreAppsRow", "moreAppsSectionDescription"], + "$key.tsx": [ + "brokeredConnectionDescription", + "brokeredReach", + "brokeredDisconnectNote", + "brokeredProvenance", + ], + "composio.tsx": ["directoryRouteDecision"], +}; + +const NO_VENDOR_LITERAL = ["index.tsx", "$key.tsx"]; + +test("the provider-neutral plugins pages carry no Composio literal", () => { + expect(NO_VENDOR_LITERAL.length).toBe(2); + const offenders: string[] = []; + for (const name of NO_VENDOR_LITERAL) { + const text = load(name); + expect(text.length).toBeGreaterThan(1000); + text.split("\n").forEach((line, i) => { + if (/Composio/.test(line)) + offenders.push(`${name}:${i + 1}: ${line.trim()}`); + }); + } + expect(offenders).toEqual([]); +}); + +test("each plugins page calls the vocabulary functions it should", () => { + expect(Object.keys(VOCAB_CALLERS).length).toBe(3); + const missing: string[] = []; + for (const [name, fns] of Object.entries(VOCAB_CALLERS)) { + const text = load(name); + for (const fn of fns) { + if (!new RegExp(`\\b${fn}\\(`).test(text)) + missing.push(`${name} -> ${fn}`); + } + } + expect(missing).toEqual([]); +}); From 596bc6715b5526009e3e9c6316699e43bf948c3e Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:07:59 +0200 Subject: [PATCH 067/232] fix(arcade): say so when the plugins read fails instead of drawing a blank directory useQuery leaves data undefined permanently on error, so the route gate answered pending forever. Render an alert on isError, before the gate, without drawing the directory. Co-Authored-By: Claude Opus 5 (1M context) --- .../routes/_authed/admin/plugins/composio.tsx | 13 +++ .../composio-route-plugins-error.test.tsx | 109 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 app/tests/composio-route-plugins-error.test.tsx diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx index c7605dc11..f46f08441 100644 --- a/app/src/routes/_authed/admin/plugins/composio.tsx +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -155,6 +155,19 @@ export function matchingApps(apps: ComposioApp[]): ComposioApp[] { */ function RouteComponent() { const plugins = useQuery(pluginsPageQueryOptions()); + /* + * `data` stays undefined PERMANENTLY on a failed read, not only while loading, so the gate below + * would answer "pending" forever and draw a blank page. A failure is said, and the directory is + * not drawn: it would fire a request against a provider nobody has confirmed. + */ + if (plugins.isError) { + return ( +

+ This deployment's plugin settings could not be read, so nothing was + shown. +

+ ); + } const decision = directoryRouteDecision("composio", plugins.data); if (decision === "redirect") return ; if (decision === "pending") return null; diff --git a/app/tests/composio-route-plugins-error.test.tsx b/app/tests/composio-route-plugins-error.test.tsx new file mode 100644 index 000000000..410a9ab53 --- /dev/null +++ b/app/tests/composio-route-plugins-error.test.tsx @@ -0,0 +1,109 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import { Route as ComposioRoute } from "@/routes/_authed/admin/plugins/composio"; + +/** + * A FAILED `/api/plugins` READ SAYS SO, rather than drawing a blank page. + * + * The route holds back until the plugins page names the provider, and `useQuery` leaves `data` + * undefined permanently on an error, so without its own branch the gate answers "pending" forever. + * The directory must also stay undrawn: it would request apps from a provider nobody confirmed. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const requested: string[] = []; + +/* + * Capture and restore of the exported `Route` singleton, verbatim from `agent-roster-error.test.tsx` + * and `brokered-account-row.test.tsx` and for the reason recorded there: `.update()` merges into the + * live object, `createRouter()` derives `_id`/`parentRoute` off it, and nothing re-runs `init()` on + * a replay — so a render here would otherwise leave the real router pointed at a decoy parent. + */ +const originalOptions = { ...ComposioRoute.options }; +afterEach(() => { + Object.assign(ComposioRoute.options, originalOptions); +}); + +function renderPicker() { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const pluginsRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () => null, + }); + const wired = ( + ComposioRoute as unknown as { + update: (options: unknown) => typeof ComposioRoute; + } + ).update({ + id: "/plugins/composio", + path: "/plugins/composio", + getParentRoute: () => adminRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([adminRoute.addChildren([pluginsRoute, wired])]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ + initialEntries: ["/admin/plugins/composio"], + }), + }); + return render( + + + , + ); +} + +test("a failed plugins read renders an alert and never asks for the directory", async () => { + requested.length = 0; + global.fetch = (async (input: RequestInfo | URL) => { + const path = String(input).split("?")[0] ?? ""; + requested.push(path); + return new Response(JSON.stringify({ error: "boom" }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const view = renderPicker(); + + const alert = await view.findByRole("alert"); + expect(alert.textContent).toContain("plugin settings could not be read"); + expect(view.queryByLabelText("Search Composio apps")).toBeNull(); + expect(requested).toContain("/api/plugins"); + expect(requested).not.toContain("/api/plugins/composio/apps"); +}); From e32fbd6092548dd7d73a0a1dbc48d24131f59534 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:14:33 +0200 Subject: [PATCH 068/232] fix(arcade): catch the all-caps COMPOSIO spelling in the vocabulary wiring guard The guard added in 80e15a2 tested /Composio/ case-sensitively. The narrowing away from case-insensitive was right -- `composioConfigured` and the /admin/plugins/composio route path are legitimate and must stay legal -- but it stopped one spelling short of the string it most needs to catch. provider-vocabulary.ts:37 holds `envVar: "COMPOSIO_API_KEY"`, rendered into moreAppsRow(...).description as "Set COMPOSIO_API_KEY on this deployment." -- the sentence that must read ARCADE_API_KEY on an Arcade deployment. An env-var name is exactly the string an editor inlines as a constant, so the regression the guard names in its own docblock was still available in its likeliest form: /Composio/.test("Set COMPOSIO_API_KEY on this deployment.") -> false /Composio/.test("Browse Composio") -> true /Composio/.test("composioConfigured") -> false Of the three spellings it caught one, correctly missed the harmless one, and missed the harmful one. Widened to /Composio|COMPOSIO/, deliberately NOT case-insensitive. Verified green on the current tree (grep for COMPOSIO in both guarded files returns nothing), and verified RED by planting const _plantedOffender = "Set COMPOSIO_API_KEY on this deployment."; in index.tsx: the guard failed naming `index.tsx:6` with the line. index.tsx was then restored and confirmed byte-identical to HEAD. app 1169 pass / 0 fail / 174 files; typecheck, format:check and lint clean. Co-Authored-By: Claude Opus 5 (1M context) --- app/tests/admin-plugins-vocabulary-wiring.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/tests/admin-plugins-vocabulary-wiring.test.ts b/app/tests/admin-plugins-vocabulary-wiring.test.ts index fd20088b1..fefab0f2f 100644 --- a/app/tests/admin-plugins-vocabulary-wiring.test.ts +++ b/app/tests/admin-plugins-vocabulary-wiring.test.ts @@ -16,7 +16,8 @@ * Reads the route sources as TEXT (like arcade-no-browser-calls.test.ts) and * imports nothing from them. Comments are stripped first, so docblocks may * still explain the vendor. Then: - * 1. `index.tsx` and `$key.tsx` contain no capitalised "Composio" (string + * 1. `index.tsx` and `$key.tsx` contain no capitalised "Composio" NOR the + * all-caps "COMPOSIO" (string * literal, JSX text or test id). * 2. each vocabulary function appears in the file that should call it. * @@ -63,7 +64,13 @@ test("the provider-neutral plugins pages carry no Composio literal", () => { const text = load(name); expect(text.length).toBeGreaterThan(1000); text.split("\n").forEach((line, i) => { - if (/Composio/.test(line)) + // Capitalised AND all-caps. `COMPOSIO_API_KEY` is a user-visible string 1.8 moved + // into the vocabulary (`envVar`), rendered as "Set COMPOSIO_API_KEY on this + // deployment." — it must read ARCADE_API_KEY on an Arcade deployment, and an + // env-var name is exactly the string an editor inlines as a constant. NOT + // case-insensitive: `composioConfigured` and the `/admin/plugins/composio` route + // path are legitimate and must stay legal. + if (/Composio|COMPOSIO/.test(line)) offenders.push(`${name}:${i + 1}: ${line.trim()}`); }); } From a429d4f98350e57a3d6933f36f49c8073a95873f Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:25:19 +0200 Subject: [PATCH 069/232] feat(arcade): give the active provider a directory screen so the measured lines render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D8 coverage warning and the D32 consent-breadth line were computed, unit-tested and drawn on no deployment anybody runs. Both live on a directory row; the only directory route redirected away on every Arcade deployment; and Composio, the one provider the route did answer for, publishes the counts as explicit null, so both functions correctly said nothing. Their "rendered on the enablement step" acceptance was satisfied by renderToStaticMarkup in tests and by nothing else. A warning computed and never shown is the same as no warning, and a feature nobody can navigate to is not shipped. The screen moves out of the route and becomes `components/plugins/broker-directory.tsx` — the debounce, the kept refusal, the in-flight slug set and the rows carried over verbatim, with their docblocks, because those docblocks record shipped bugs: the TanStack MutationObserver rebuild erasing a refusal, and a second Add reverting the first row. The pure functions move to `lib/plugins/enablement-notes.ts`, a `.ts` module a non-DOM test can import. Both directory routes are now a guard and a delegation and nothing else, and neither imports the other: that is how two screens that should be one begin to drift. Every vendor word on that screen comes from `providerVocabulary(provider)`, with no `provider === "composio"` branch anywhere in it, and the row's test id is now `directory-` — a provider-blind module may not spell a vendor, and the old id is not user-visible. All six Composio sentences derive byte-identically from `name` and `directoryLabel`. `moreAppsRow` loses its vendor comparison. `directoryDescription` carries Composio's configured-branch sentence verbatim and an Arcade sentence beside it that claims no app count, so the configured branch links for both providers and tests the configuration rather than the vendor. The row's `Link` reads `providerVocabulary(provider).directoryPath`, which is narrowed from `string` to the literal union over the generated route tree — that union IS the check that the vocabulary names a route that exists, so no cast. `routeTree.gen.ts` was regenerated by the tanstackRouter Vite plugin and not hand-edited. The wiring guard gains the rule that stranded this: no plugins route may hard-code `/admin/plugins/composio` or `/admin/plugins/arcade`, which is the lowercase spelling its capitalised-vendor rule cannot see. `composio.tsx`'s exemption from that rule is deleted — after the extraction the file holds no vendor literal, and an exemption kept after its reason has gone is an escape hatch waiting to be used a second time. FOLDS IN AMENDMENT 19 RULING 4 (curator). `consentBreadth`'s `permissionCount === 0` branch had no singular handling while its siblings did, so Apollo — the fixture's only no-permission toolkit, with exactly one action — read "approving any action in this app approves all 1 of them". That shape now says "This app has a single action, and approving it approves the app."; the plural keeps "all N of them"; rows two and three are unchanged; and "1 separate permission across 1 action" is left alone, because stilted is not wrong and "all 1 of them" is a different thing. The Apollo test's `/approves/` — which is what let it through — is replaced with the exact string. No server work: `GET`/`POST /composio/apps` already call the ACTIVE broker, and that route path is a URL, not vocabulary. No Composio behaviour changes. No Arcade authorize surface is added here. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/plugins/broker-directory.tsx | 250 ++++++++++++++ app/src/lib/plugins/enablement-notes.ts | 94 ++++++ app/src/lib/plugins/provider-vocabulary.ts | 46 ++- app/src/routeTree.gen.ts | 22 ++ .../routes/_authed/admin/plugins/arcade.tsx | 49 +++ .../routes/_authed/admin/plugins/composio.tsx | 314 +----------------- .../routes/_authed/admin/plugins/index.tsx | 10 +- .../admin-plugins-vocabulary-wiring.test.ts | 76 ++++- app/tests/arcade-admin-plugins.test.tsx | 35 +- app/tests/arcade-breadth.test.tsx | 20 +- app/tests/arcade-coverage.test.tsx | 6 +- app/tests/arcade-directory-screen.test.tsx | 197 +++++++++++ app/tests/composio-picker-add-error.test.tsx | 5 +- app/tests/composio-picker.test.tsx | 10 +- 14 files changed, 776 insertions(+), 358 deletions(-) create mode 100644 app/src/components/plugins/broker-directory.tsx create mode 100644 app/src/lib/plugins/enablement-notes.ts create mode 100644 app/src/routes/_authed/admin/plugins/arcade.tsx create mode 100644 app/tests/arcade-directory-screen.test.tsx diff --git a/app/src/components/plugins/broker-directory.tsx b/app/src/components/plugins/broker-directory.tsx new file mode 100644 index 000000000..5ac2cfe72 --- /dev/null +++ b/app/src/components/plugins/broker-directory.tsx @@ -0,0 +1,250 @@ +import { IconPlug } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import * as React from "react"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { + consentBreadth, + coverageWarning, + matchingApps, +} from "@/lib/plugins/enablement-notes"; +import { + enableComposioAppMutationOptions, + invalidatePlugins, +} from "@/lib/plugins/mutations"; +import { + type PluginsProvider, + providerVocabulary, +} from "@/lib/plugins/provider-vocabulary"; +import { + type ComposioApp, + composioAppsQueryOptions, +} from "@/lib/plugins/queries"; +import { queryClient } from "@/query-client"; + +/** + * The active broker's directory, searched rather than listed. + * + * The catalogue on the previous screen is a reviewed handful, and every entry there is a decision + * somebody made about a vendor. This is a directory nobody here has reviewed, which is why it is a + * search field and not a list: the only sensible entry point into a directory that size is the name + * of the app you already came looking for. + * + * Adding one is account-wide, and it still reads nothing. Every app here is reached as whoever is + * asking, so a person's own connection is what makes their own mail or messages readable — which is + * made on their settings page, not here. + * + * A COMPONENT AND NOT A ROUTE, so both directory routes are the same screen rather than two that + * drift. A route file importing another route file is how that drift starts, so neither does. Every + * vendor word is read from {@link providerVocabulary}; nothing here spells a vendor, and nothing + * here branches on which one it is — a `provider === "composio"` test inside a screen is the same + * mistake wearing a conditional. + */ +export function BrokerDirectory({ provider }: { provider: PluginsProvider }) { + const { name, directoryLabel } = providerVocabulary(provider); + const [search, setSearch] = React.useState(""); + /* + * Debounced, as on People, and for a stronger reason: every distinct term is a cache key of its + * own and a request to the vendor, so typing a name un-debounced is a round trip per keystroke + * against somebody else's rate limit. + */ + const [query, setQuery] = React.useState(""); + React.useEffect(() => { + const timer = setTimeout(() => setQuery(search), 250); + return () => clearTimeout(timer); + }, [search]); + + const apps = useQuery(composioAppsQueryOptions(query)); + /* + * WHAT A FAILED ADD SAID, KEPT WHERE A SECOND ADD CANNOT ERASE IT. + * + * ONE OBSERVER SERVES EVERY BUTTON, which is what `enable.variables` below is already written + * around — and the same fact makes `enable.error` unusable as the place a refusal lives. + * `MutationObserver.mutate` does `this.#currentMutation?.removeObserver(this)` and rebuilds, so + * `error`, `isPending` and `variables` describe ONLY the most recent press. An admin pressing Add + * on Slack and then, a beat later, on Gmail watched Slack's row revert from "Adding…" to "Add" + * with its request still in flight — and when Slack came back 409, or 503, or carrying the + * vendor's own sentence about a bad key, the banner never showed it, because `enable.error` was + * Gmail's and Gmail's was null. What was left was a row saying "Add" and no record of a failure. + * + * THE MUTATION-LEVEL `onError` STILL FIRES AFTER `removeObserver`, which is why this works and + * reading the observer does not. It is the shape the sibling grant screen already uses + * (`$key_.bots.$agentId.tsx`), arriving at the one screen that was reading the observer instead. + */ + const [enableError, setEnableError] = React.useState(null); + /* + * AND WHICH ROWS ARE IN FLIGHT, FOR THE SAME REASON AND OUT OF THE SAME HOOKS. + * + * `enable.isPending && enable.variables?.slug === entry.slug` is the observer again: it names the + * most recent press and no other, so pressing Add on a second app put the FIRST row back to "Add" + * with its request still open — an invitation to press it again, which asks the server to enable + * an app it is already enabling. A set of slugs is what survives the rebuild, added in `onMutate` + * and removed in `onSettled` so it drains on a refusal exactly as it does on a success. + */ + const [adding, setAdding] = React.useState>(new Set()); + const enable = useMutation({ + ...enableComposioAppMutationOptions(queryClient), + onMutate: (input: { slug: string }) => { + setEnableError(null); + setAdding((held) => new Set(held).add(input.slug)); + }, + onError: (thrown: Error) => setEnableError(thrown.message), + /* + * SPELLED OUT RATHER THAN INHERITED, because the spread above carries an `onSettled` of its own + * — `invalidatePlugins` — and a second one here replaces it rather than running beside it. The + * refetch is what turns the added row into "Added", so dropping it would leave the list claiming + * the app is still addable until something else invalidated. + */ + onSettled: (_data, _error, input) => { + setAdding((held) => { + const left = new Set(held); + left.delete(input.slug); + return left; + }); + return invalidatePlugins(queryClient); + }, + }); + const listed = matchingApps(apps.data?.apps ?? []); + + return ( + + + {enableError ? ( +

+ {enableError} +

+ ) : null} + + setSearch(event.target.value)} + placeholder="Search by app name" + value={search} + /> + + {/* Pending, error, empty, rows — pending first, so no sentence asserts anything mid-fetch. */} + {apps.isPending ? null : apps.error ? ( +

+ {/* + * The server's own sentence, as the enable failure above already does. A keyless + * deployment is refused with one naming the key's environment variable, and that name is + * the single thing an operator who reached this URL needs to read; the derived line + * stays as the fallback for a failure that arrived carrying no message of its own. + */} + {apps.error.message || `${name}'s app directory could not be read.`} +

+ ) : listed.length === 0 ? ( + + {query + ? `Nothing in ${name}'s directory matches "${query}".` + : `${name} returned no apps.`} + + ) : ( + + {listed.map((entry, index) => { + // One mutation serves every row, so which rows are mid-request is tracked beside it + // rather than read off it. See `adding` above. + const isAdding = adding.has(entry.slug); + + return ( + + {/* The test id names the screen, not the vendor: this module draws whichever + broker is active, and the id is not user-visible. */} + + {/* The vendor's own mark where the broker supplies one, and a plug where it does + not, so a list of third parties still has a fixed left edge. */} + + {entry.logo ? ( + + ) : ( + + )} + + + {entry.name} + {entry.description} + {entry.enabled ? null : } + {entry.enabled ? null : } + + + {/* The size of the decision, stated before it is made: an app is not one + tool, and "167 actions" is what says so. */} + + {entry.actionCount} actions + + {/* + * An app this deployment already has says so rather than offering Add again. + * A second Add would ask the server to record a connector it already holds, + * and there is nothing on the row to suggest that would be harmless. + */} + {entry.enabled ? ( + + Added + + ) : ( + + )} + + + {index !== listed.length - 1 && } + + ); + })} + + )} +
+
+ ); +} + +/** The warning on a row, shown before Add is pressed. Draws nothing when there is none. */ +export function CoverageNote({ app }: { app: ComposioApp }) { + const warning = coverageWarning(app); + if (!warning) return null; + return ( +

+ {warning} +

+ ); +} + +/** The breadth line on a row, shown before Add is pressed. Draws nothing when there is none. */ +export function BreadthNote({ app }: { app: ComposioApp }) { + const breadth = consentBreadth(app); + if (!breadth) return null; + return ( +

+ {breadth} +

+ ); +} diff --git a/app/src/lib/plugins/enablement-notes.ts b/app/src/lib/plugins/enablement-notes.ts new file mode 100644 index 000000000..7791c2988 --- /dev/null +++ b/app/src/lib/plugins/enablement-notes.ts @@ -0,0 +1,94 @@ +import type { ComposioApp } from "./queries"; + +/** + * What a directory row says about an app before anybody presses Add. + * + * Pure, and in a `.ts` file rather than beside the screen, so the three decisions a row rests on can + * be asserted without a DOM, a router and a query client — and so the screen that draws them is free + * to be a provider-blind component rather than a route. + */ + +/** + * The D8 coverage warning for an app, or null when there is nothing to say. + * + * Driven by `classifiedCount` against `actionCount` and nothing else. `classifiedCount: 0` is + * MEASURED, nothing classified, so it warns; `null` is NOT MEASURED (a Composio app) so it says + * nothing. Those are opposite sentences, and `?? 0` would collapse one into the other. + * + * The classification is measured server-side from the fetched listing and never re-derived here: + * never inferred from the `Api` suffix (Arcade's naming convention, not a contract — a guess wearing + * a measurement's clothes), and never from `classify()`'s verdict, which is TOTAL: its closed + * destructive-write verdict for absent metadata is byte-identical to its verdict for a genuinely + * destructive action, so counting "did classify return something" counts nothing. + * + * Phrased as what it will feel like, not as a percentage. + */ +export function coverageWarning( + app: Pick, +): string | null { + if (app.classifiedCount === null) return null; + if (app.actionCount > 0 && app.classifiedCount === 0) { + return "This provider has not said which of its actions only read, so every action is treated as a destructive write and every call will ask for approval."; + } + return null; +} + +/** + * The D32 consent-breadth line for an app, or null when there is nothing to say. + * + * Read off the counts the server measured from the same listing, never recomputed here. `null` is + * NOT MEASURED and draws nothing; a measured zero is a different sentence, so no count is ever + * defaulted with `?? 0`. + * + * An action with no scopes is not its own permission: the connection itself is the grant, so any + * approval covers it. That is why a mixed app says how many actions need no permission of their own, + * rather than letting one permission over two actions read as "one permission covering both". + * + * The wholly-unscoped case is SPLIT ON THE SINGULAR, where its siblings already were. One action is + * not "all 1 of them": the fixture's only no-permission toolkit has exactly one action, so the + * grammatical error was the sentence that shipped rather than an edge nobody reaches. The stilted + * "1 separate permission across 1 action" below is left alone — stilted is not wrong, and "all 1 of + * them" is a different thing. + * + * It states breadth and never confinement. Whether one approval stays inside the action that asked + * for it is the provider's property, measured per app, so nothing here promises it. + */ +export function consentBreadth( + app: Pick< + ComposioApp, + "actionCount" | "permissionCount" | "scopedActionCount" + >, +): string | null { + const { actionCount, permissionCount, scopedActionCount } = app; + if (permissionCount === null || scopedActionCount === null) return null; + if (actionCount <= 0) return null; + if (permissionCount === 0) { + return actionCount === 1 + ? "This app has a single action, and approving it approves the app." + : `Approving any action in this app approves all ${actionCount} of them.`; + } + const permissions = `${permissionCount} separate ${permissionCount === 1 ? "permission" : "permissions"}`; + const actions = `${actionCount} ${actionCount === 1 ? "action" : "actions"}`; + const base = `This app uses ${permissions} across ${actions}.`; + const unscoped = actionCount - scopedActionCount; + if (unscoped <= 0) return base; + return unscoped === 1 + ? `${base} A further 1 requests no permission of its own and becomes callable with any approval here.` + : `${base} A further ${unscoped} request no permission of their own and become callable with any approval here.`; +} + +/** + * The apps to draw, in the order to draw them. + * + * A function rather than a `.sort()` inside the map, so the two things a row is decided from — the + * order, and the marker saying an app is already here — can be asserted without a DOM, a router and + * a query client. See `tests/composio-picker.test.tsx`. + * + * A copy, because the array belongs to TanStack Query's cache and is the same object on every + * render; sorting it in place would rewrite what the cache holds. By name rather than by the + * vendor's own order, which is popularity — useful for a landing page, useless for finding the one + * app somebody typed half the name of. + */ +export function matchingApps(apps: ComposioApp[]): ComposioApp[] { + return [...apps].sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/app/src/lib/plugins/provider-vocabulary.ts b/app/src/lib/plugins/provider-vocabulary.ts index af3732b27..bf47b74b8 100644 --- a/app/src/lib/plugins/provider-vocabulary.ts +++ b/app/src/lib/plugins/provider-vocabulary.ts @@ -26,8 +26,23 @@ export type ProviderVocabulary = { directoryLabel: string; /** The environment variable an operator sets to turn the broker on. */ envVar: string; - /** The route path of the vendor's directory screen. */ - directoryPath: string; + /** + * The route path of the vendor's directory screen. + * + * A LITERAL UNION AND NEVER `string`, because that union is TanStack Router's generated route + * tree: a path that no route file answers stops compiling here rather than 404ing in somebody's + * browser. That is exactly how an earlier draft named a screen that did not exist, so a `as const` + * cast at the `` call site would be casting away the only check there is. + */ + directoryPath: "/admin/plugins/composio" | "/admin/plugins/arcade"; + /** + * What the "More apps" row says once the key is set. + * + * A FIELD rather than a `provider === "composio"` branch. Composio's sentence counts its + * directory ("a few hundred apps") and that count is Composio's, measured by nobody here; Arcade's + * says the same thing about reach without claiming a size this deployment has not counted. + */ + directoryDescription: string; }; const VOCABULARY: Record = { @@ -36,12 +51,16 @@ const VOCABULARY: Record = { directoryLabel: "Browse Composio", envVar: "COMPOSIO_API_KEY", directoryPath: "/admin/plugins/composio", + directoryDescription: + "A few hundred apps, reached as whoever is asking. Each person connects their own account.", }, arcade: { name: "Arcade", directoryLabel: "Browse Arcade", envVar: "ARCADE_API_KEY", directoryPath: "/admin/plugins/arcade", + directoryDescription: + "A directory this deployment has not reviewed, reached as whoever is asking. Each person connects their own account.", }, }; @@ -59,8 +78,10 @@ export function moreAppsSectionDescription(provider: PluginsProvider): string { /** * The one row under "More apps". * - * `linked` is whether the row goes anywhere. Only Composio has a directory screen today, so an - * Arcade row states its setting and links nowhere; a Composio row links exactly when configured. + * `linked` is whether the row goes anywhere. Both providers have a directory screen, so the row + * links exactly when the key is configured — and the function tests the configuration and never the + * vendor. An unconfigured row states its setting and links nowhere, because there is nowhere to go + * until the key is set. */ export type MoreAppsRow = { title: string; @@ -72,7 +93,8 @@ export function moreAppsRow( provider: PluginsProvider, configured: boolean, ): MoreAppsRow { - const { name, directoryLabel, envVar } = VOCABULARY[provider]; + const { name, directoryLabel, envVar, directoryDescription } = + VOCABULARY[provider]; if (!configured) { return { title: name, @@ -80,18 +102,10 @@ export function moreAppsRow( linked: false, }; } - if (provider === "composio") { - return { - title: directoryLabel, - description: - "A few hundred apps, reached as whoever is asking. Each person connects their own account.", - linked: true, - }; - } return { - title: name, - description: `${name} is configured on this deployment. Each person connects their own account.`, - linked: false, + title: directoryLabel, + description: directoryDescription, + linked: true, }; } diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index ca53f4c05..e4b110fc9 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -52,6 +52,7 @@ import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed import { Route as AuthedAdminComponentsNameRouteImport } from './routes/_authed/admin/components/$name' import { Route as AuthedAdminPluginsIndexRouteImport } from './routes/_authed/admin/plugins/index' import { Route as AuthedAdminPluginsKeyRouteImport } from './routes/_authed/admin/plugins/$key' +import { Route as AuthedAdminPluginsArcadeRouteImport } from './routes/_authed/admin/plugins/arcade' import { Route as AuthedAdminPluginsComposioRouteImport } from './routes/_authed/admin/plugins/composio' import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './routes/_authed/settings/components-gallery/index' import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name' @@ -288,6 +289,12 @@ const AuthedAdminPluginsKeyRoute = AuthedAdminPluginsKeyRouteImport.update({ path: '/plugins/$key', getParentRoute: () => AuthedAdminRouteRoute, } as any) +const AuthedAdminPluginsArcadeRoute = + AuthedAdminPluginsArcadeRouteImport.update({ + id: '/plugins/arcade', + path: '/plugins/arcade', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedAdminPluginsComposioRoute = AuthedAdminPluginsComposioRouteImport.update({ id: '/plugins/composio', @@ -423,6 +430,7 @@ export interface FileRoutesByFullPath { '/group/new': typeof AuthedAppGroupNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/admin/plugins/arcade': typeof AuthedAdminPluginsArcadeRoute '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute @@ -480,6 +488,7 @@ export interface FileRoutesByTo { '/group/new': typeof AuthedAppGroupNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/admin/plugins/arcade': typeof AuthedAdminPluginsArcadeRoute '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute @@ -542,6 +551,7 @@ export interface FileRoutesById { '/_authed/_app/group/new': typeof AuthedAppGroupNewRoute '/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/_authed/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute + '/_authed/admin/plugins/arcade': typeof AuthedAdminPluginsArcadeRoute '/_authed/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute '/_authed/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/_authed/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute @@ -603,6 +613,7 @@ export interface FileRouteTypes { | '/group/new' | '/admin/components/$name' | '/admin/plugins/$key' + | '/admin/plugins/arcade' | '/admin/plugins/composio' | '/settings/components-gallery/$name' | '/settings/connected-accounts/$key' @@ -660,6 +671,7 @@ export interface FileRouteTypes { | '/group/new' | '/admin/components/$name' | '/admin/plugins/$key' + | '/admin/plugins/arcade' | '/admin/plugins/composio' | '/settings/components-gallery/$name' | '/settings/connected-accounts/$key' @@ -721,6 +733,7 @@ export interface FileRouteTypes { | '/_authed/_app/group/new' | '/_authed/admin/components/$name' | '/_authed/admin/plugins/$key' + | '/_authed/admin/plugins/arcade' | '/_authed/admin/plugins/composio' | '/_authed/settings/components-gallery/$name' | '/_authed/settings/connected-accounts/$key' @@ -1051,6 +1064,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminPluginsKeyRouteImport parentRoute: typeof AuthedAdminRouteRoute } + '/_authed/admin/plugins/arcade': { + id: '/_authed/admin/plugins/arcade' + path: '/plugins/arcade' + fullPath: '/admin/plugins/arcade' + preLoaderRoute: typeof AuthedAdminPluginsArcadeRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/admin/plugins/composio': { id: '/_authed/admin/plugins/composio' path: '/plugins/composio' @@ -1181,6 +1201,7 @@ interface AuthedAdminRouteRouteChildren { AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminComponentsNameRoute: typeof AuthedAdminComponentsNameRoute AuthedAdminPluginsKeyRoute: typeof AuthedAdminPluginsKeyRoute + AuthedAdminPluginsArcadeRoute: typeof AuthedAdminPluginsArcadeRoute AuthedAdminPluginsComposioRoute: typeof AuthedAdminPluginsComposioRoute AuthedAdminComponentsIndexRoute: typeof AuthedAdminComponentsIndexRoute AuthedAdminPluginsIndexRoute: typeof AuthedAdminPluginsIndexRoute @@ -1203,6 +1224,7 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute, AuthedAdminPluginsKeyRoute: AuthedAdminPluginsKeyRoute, + AuthedAdminPluginsArcadeRoute: AuthedAdminPluginsArcadeRoute, AuthedAdminPluginsComposioRoute: AuthedAdminPluginsComposioRoute, AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute, AuthedAdminPluginsIndexRoute: AuthedAdminPluginsIndexRoute, diff --git a/app/src/routes/_authed/admin/plugins/arcade.tsx b/app/src/routes/_authed/admin/plugins/arcade.tsx new file mode 100644 index 000000000..4fd37bbf3 --- /dev/null +++ b/app/src/routes/_authed/admin/plugins/arcade.tsx @@ -0,0 +1,49 @@ +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { BrokerDirectory } from "@/components/plugins/broker-directory"; +import { directoryRouteDecision } from "@/lib/plugins/provider-vocabulary"; +import { pluginsPageQueryOptions } from "@/lib/plugins/queries"; + +/** + * Arcade's directory, searched rather than listed. + * + * The mirror of the sibling directory route, and deliberately nothing more. The screen itself is + * `components/plugins/broker-directory.tsx`; the directory endpoint behind it is provider-blind and + * answers from whichever broker this deployment runs, so this route adds a guard and a provider name + * and no behaviour of its own. + * + * Until this existed the coverage warning and the consent-breadth line were drawn on no deployment + * anybody runs: they live on a directory row, the only directory route redirected away on an Arcade + * deployment, and Arcade is the only provider that publishes the counts they are computed from. + */ +export const Route = createFileRoute("/_authed/admin/plugins/arcade")({ + component: RouteComponent, +}); + +/** + * The route guard: this directory answers only on an Arcade deployment. + * + * A route answering for the wrong provider is how an enablement flow gets spent against the wrong + * project. Nothing is drawn, and so no directory request is made, until the page has said which + * provider this is. + */ +function RouteComponent() { + const plugins = useQuery(pluginsPageQueryOptions()); + /* + * `data` stays undefined PERMANENTLY on a failed read, not only while loading, so the gate below + * would answer "pending" forever and draw a blank page. A failure is said, and the directory is + * not drawn: it would fire a request against a provider nobody has confirmed. + */ + if (plugins.isError) { + return ( +

+ This deployment's plugin settings could not be read, so nothing was + shown. +

+ ); + } + const decision = directoryRouteDecision("arcade", plugins.data); + if (decision === "redirect") return ; + if (decision === "pending") return null; + return ; +} diff --git a/app/src/routes/_authed/admin/plugins/composio.tsx b/app/src/routes/_authed/admin/plugins/composio.tsx index f46f08441..4a738499e 100644 --- a/app/src/routes/_authed/admin/plugins/composio.tsx +++ b/app/src/routes/_authed/admin/plugins/composio.tsx @@ -1,45 +1,18 @@ -import { IconPlug } from "@tabler/icons-react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Navigate } from "@tanstack/react-router"; -import * as React from "react"; -import { - PageEmpty, - PageRows, - PageSection, - PageShell, -} from "@/components/layout/page-shell"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from "@/components/ui/item"; -import { Separator } from "@/components/ui/separator"; -import { - enableComposioAppMutationOptions, - invalidatePlugins, -} from "@/lib/plugins/mutations"; +import { BrokerDirectory } from "@/components/plugins/broker-directory"; import { directoryRouteDecision } from "@/lib/plugins/provider-vocabulary"; -import { - type ComposioApp, - composioAppsQueryOptions, - pluginsPageQueryOptions, -} from "@/lib/plugins/queries"; -import { queryClient } from "@/query-client"; +import { pluginsPageQueryOptions } from "@/lib/plugins/queries"; /** * Composio's directory, searched rather than listed. * - * The catalogue on the previous screen is a reviewed handful, and every entry there is a decision - * somebody made about a vendor. This is a few hundred apps that nobody here has reviewed, which is - * why it is a search field and not a list: the only sensible entry point into a directory that size - * is the name of the app you already came looking for. + * The screen itself is `components/plugins/broker-directory.tsx`, which draws whichever broker is + * active and reads every vendor word from the vocabulary. This file is the guard and the delegation, + * and nothing else: the sibling route is its mirror, and a route file importing another route file + * is how two screens that should be one begin to drift. * - * Adding one is account-wide, and it still reads nothing. Every app here is reached as whoever is + * Adding an app is account-wide, and it still reads nothing. Every app here is reached as whoever is * asking, so a person's own connection is what makes their own mail or messages readable — which is * made on their settings page, not here. */ @@ -47,105 +20,6 @@ export const Route = createFileRoute("/_authed/admin/plugins/composio")({ component: RouteComponent, }); -/** - * The D8 coverage warning for an app, or null when there is nothing to say. - * - * Driven by `classifiedCount` against `actionCount` and nothing else. `classifiedCount: 0` is - * MEASURED, nothing classified, so it warns; `null` is NOT MEASURED (a Composio app) so it says - * nothing. Those are opposite sentences, and `?? 0` would collapse one into the other. - * - * The classification is measured server-side from the fetched listing and never re-derived here: - * never inferred from the `Api` suffix (Arcade's naming convention, not a contract — a guess wearing - * a measurement's clothes), and never from `classify()`'s verdict, which is TOTAL: its closed - * destructive-write verdict for absent metadata is byte-identical to its verdict for a genuinely - * destructive action, so counting "did classify return something" counts nothing. - * - * Phrased as what it will feel like, not as a percentage. - */ -export function coverageWarning( - app: Pick, -): string | null { - if (app.classifiedCount === null) return null; - if (app.actionCount > 0 && app.classifiedCount === 0) { - return "This provider has not said which of its actions only read, so every action is treated as a destructive write and every call will ask for approval."; - } - return null; -} - -/** The warning on a row, shown before Add is pressed. Draws nothing when there is none. */ -export function CoverageNote({ app }: { app: ComposioApp }) { - const warning = coverageWarning(app); - if (!warning) return null; - return ( -

- {warning} -

- ); -} - -/** - * The D32 consent-breadth line for an app, or null when there is nothing to say. - * - * Read off the counts the server measured from the same listing, never recomputed here. `null` is - * NOT MEASURED and draws nothing; a measured zero is a different sentence, so no count is ever - * defaulted with `?? 0`. - * - * An action with no scopes is not its own permission: the connection itself is the grant, so any - * approval covers it. That is why a mixed app says how many actions need no permission of their own, - * rather than letting one permission over two actions read as "one permission covering both". - * - * It states breadth and never confinement. Whether one approval stays inside the action that asked - * for it is the provider's property, measured per app, so nothing here promises it. - */ -export function consentBreadth( - app: Pick< - ComposioApp, - "actionCount" | "permissionCount" | "scopedActionCount" - >, -): string | null { - const { actionCount, permissionCount, scopedActionCount } = app; - if (permissionCount === null || scopedActionCount === null) return null; - if (actionCount <= 0) return null; - if (permissionCount === 0) { - return `Approving any action in this app approves all ${actionCount} of them.`; - } - const permissions = `${permissionCount} separate ${permissionCount === 1 ? "permission" : "permissions"}`; - const actions = `${actionCount} ${actionCount === 1 ? "action" : "actions"}`; - const base = `This app uses ${permissions} across ${actions}.`; - const unscoped = actionCount - scopedActionCount; - if (unscoped <= 0) return base; - return unscoped === 1 - ? `${base} A further 1 requests no permission of its own and becomes callable with any approval here.` - : `${base} A further ${unscoped} request no permission of their own and become callable with any approval here.`; -} - -/** The breadth line on a row, shown before Add is pressed. Draws nothing when there is none. */ -export function BreadthNote({ app }: { app: ComposioApp }) { - const breadth = consentBreadth(app); - if (!breadth) return null; - return ( -

- {breadth} -

- ); -} - -/** - * The apps to draw, in the order to draw them. - * - * A function rather than a `.sort()` inside the map, so the two things a row is decided from — the - * order, and the marker saying an app is already here — can be asserted without a DOM, a router and - * a query client. See `tests/composio-picker.test.tsx`. - * - * A copy, because the array belongs to TanStack Query's cache and is the same object on every - * render; sorting it in place would rewrite what the cache holds. By name rather than by the - * vendor's own order, which is popularity — useful for a landing page, useless for finding the one - * app somebody typed half the name of. - */ -export function matchingApps(apps: ComposioApp[]): ComposioApp[] { - return [...apps].sort((left, right) => left.name.localeCompare(right.name)); -} - /** * The route guard: this directory answers only on a Composio deployment. * @@ -171,175 +45,5 @@ function RouteComponent() { const decision = directoryRouteDecision("composio", plugins.data); if (decision === "redirect") return ; if (decision === "pending") return null; - return ; -} - -function ComposioDirectory() { - const [search, setSearch] = React.useState(""); - /* - * Debounced, as on People, and for a stronger reason: every distinct term is a cache key of its - * own and a request to the vendor, so typing a name un-debounced is a round trip per keystroke - * against somebody else's rate limit. - */ - const [query, setQuery] = React.useState(""); - React.useEffect(() => { - const timer = setTimeout(() => setQuery(search), 250); - return () => clearTimeout(timer); - }, [search]); - - const apps = useQuery(composioAppsQueryOptions(query)); - /* - * WHAT A FAILED ADD SAID, KEPT WHERE A SECOND ADD CANNOT ERASE IT. - * - * ONE OBSERVER SERVES EVERY BUTTON, which is what `enable.variables` below is already written - * around — and the same fact makes `enable.error` unusable as the place a refusal lives. - * `MutationObserver.mutate` does `this.#currentMutation?.removeObserver(this)` and rebuilds, so - * `error`, `isPending` and `variables` describe ONLY the most recent press. An admin pressing Add - * on Slack and then, a beat later, on Gmail watched Slack's row revert from "Adding…" to "Add" - * with its request still in flight — and when Slack came back 409, or 503, or carrying Composio's - * own sentence about a bad key, the banner never showed it, because `enable.error` was Gmail's - * and Gmail's was null. What was left was a row saying "Add" and no record that anything failed. - * - * THE MUTATION-LEVEL `onError` STILL FIRES AFTER `removeObserver`, which is why this works and - * reading the observer does not. It is the shape the sibling grant screen already uses - * (`$key_.bots.$agentId.tsx`), arriving at the one screen that was reading the observer instead. - */ - const [enableError, setEnableError] = React.useState(null); - /* - * AND WHICH ROWS ARE IN FLIGHT, FOR THE SAME REASON AND OUT OF THE SAME HOOKS. - * - * `enable.isPending && enable.variables?.slug === entry.slug` is the observer again: it names the - * most recent press and no other, so pressing Add on a second app put the FIRST row back to "Add" - * with its request still open — an invitation to press it again, which asks the server to enable - * an app it is already enabling. A set of slugs is what survives the rebuild, added in `onMutate` - * and removed in `onSettled` so it drains on a refusal exactly as it does on a success. - */ - const [adding, setAdding] = React.useState>(new Set()); - const enable = useMutation({ - ...enableComposioAppMutationOptions(queryClient), - onMutate: (input: { slug: string }) => { - setEnableError(null); - setAdding((held) => new Set(held).add(input.slug)); - }, - onError: (thrown: Error) => setEnableError(thrown.message), - /* - * SPELLED OUT RATHER THAN INHERITED, because the spread above carries an `onSettled` of its own - * — `invalidatePlugins` — and a second one here replaces it rather than running beside it. The - * refetch is what turns the added row into "Added", so dropping it would leave the list claiming - * the app is still addable until something else invalidated. - */ - onSettled: (_data, _error, input) => { - setAdding((held) => { - const left = new Set(held); - left.delete(input.slug); - return left; - }); - return invalidatePlugins(queryClient); - }, - }); - const listed = matchingApps(apps.data?.apps ?? []); - - return ( - - - {enableError ? ( -

- {enableError} -

- ) : null} - - setSearch(event.target.value)} - placeholder="Search by app name" - value={search} - /> - - {/* Pending, error, empty, rows — pending first, so no sentence asserts anything mid-fetch. */} - {apps.isPending ? null : apps.error ? ( -

- {/* - * The server's own sentence, as the enable failure above already does. A keyless - * deployment is refused with one naming COMPOSIO_API_KEY, and that name is the single - * thing an operator who reached this URL needs to read; the hardcoded line stays as - * the fallback for a failure that arrived carrying no message of its own. - */} - {apps.error.message || - "Composio's app directory could not be read."} -

- ) : listed.length === 0 ? ( - - {query - ? `Nothing in Composio's directory matches "${query}".` - : "Composio returned no apps."} - - ) : ( - - {listed.map((entry, index) => { - // One mutation serves every row, so which rows are mid-request is tracked beside it - // rather than read off it. See `adding` above. - const isAdding = adding.has(entry.slug); - - return ( - - - {/* The vendor's own mark where Composio supplies one, and a plug where it does - not, so a list of third parties still has a fixed left edge. */} - - {entry.logo ? ( - - ) : ( - - )} - - - {entry.name} - {entry.description} - {entry.enabled ? null : } - {entry.enabled ? null : } - - - {/* The size of the decision, stated before it is made: an app is not one - tool, and "167 actions" is what says so. */} - - {entry.actionCount} actions - - {/* - * An app this deployment already has says so rather than offering Add again. - * A second Add would ask the server to record a connector it already holds, - * and there is nothing on the row to suggest that would be harmless. - */} - {entry.enabled ? ( - - Added - - ) : ( - - )} - - - {index !== listed.length - 1 && } - - ); - })} - - )} -
-
- ); + return ; } diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx index f1939b3b2..4a6b122e7 100644 --- a/app/src/routes/_authed/admin/plugins/index.tsx +++ b/app/src/routes/_authed/admin/plugins/index.tsx @@ -33,6 +33,7 @@ import { import { moreAppsRow, moreAppsSectionDescription, + providerVocabulary, } from "@/lib/plugins/provider-vocabulary"; /** @@ -266,7 +267,14 @@ function RouteComponent() { {moreApps.linked ? ( } + /* The ACTIVE provider's own directory. Read from the vocabulary rather than + written here: a hard-coded path is the lowercase spelling of a vendor name, + which is how this row pointed every Arcade deployment at a screen that + redirected it straight back. The field is a literal union over the generated + route tree, so a path no route answers does not compile. */ + render={ + + } size="sm" > diff --git a/app/tests/admin-plugins-vocabulary-wiring.test.ts b/app/tests/admin-plugins-vocabulary-wiring.test.ts index fefab0f2f..821642fa2 100644 --- a/app/tests/admin-plugins-vocabulary-wiring.test.ts +++ b/app/tests/admin-plugins-vocabulary-wiring.test.ts @@ -16,20 +16,32 @@ * Reads the route sources as TEXT (like arcade-no-browser-calls.test.ts) and * imports nothing from them. Comments are stripped first, so docblocks may * still explain the vendor. Then: - * 1. `index.tsx` and `$key.tsx` contain no capitalised "Composio" NOR the - * all-caps "COMPOSIO" (string - * literal, JSX text or test id). - * 2. each vocabulary function appears in the file that should call it. - * - * `composio.tsx` is exempt from (1): that route is Composio-gated by - * construction (directoryRouteDecision), so its literals are legitimate. It is - * still held to (2). + * 1. NO route file under the plugins directory contains a capitalised + * "Composio" NOR the all-caps "COMPOSIO" (string literal, JSX text or test + * id). `composio.tsx` USED to be exempt, on the argument that the route is + * Composio-gated by construction. The screen it held has moved to + * `components/plugins/broker-directory.tsx`, so the file holds no vendor + * literal and the exemption has no reason left — and an exemption kept + * after its reason has gone is an escape hatch waiting to be used a second + * time. + * 2. NO route file contains a hard-coded `/admin/plugins/composio` or + * `/admin/plugins/arcade` navigation target. THIS IS THE LOWERCASE + * SPELLING RULE (1) CANNOT SEE, and it is the one that stranded the + * enablement notes: `index.tsx` linked every deployment, Arcade ones + * included, at the Composio directory, which redirected them straight + * back — so two measured warnings rendered on no deployment that exists. + * The path belongs in `providerVocabulary().directoryPath`, whose literal + * union is checked against the generated route tree. Each file's own + * `createFileRoute("/_authed/admin/plugins/…")` id is not a navigation + * target and is excluded by the `_authed` prefix. + * 3. each vocabulary function appears in the file that should call it. * * If this goes red, route the string through provider-vocabulary.ts; do not - * loosen the guard. The scanned-count assertion stops it passing vacuously. + * loosen the guard. The scanned-count and file-length assertions stop it + * passing vacuously. */ import { expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; const DIR = join(import.meta.dir, "../src/routes/_authed/admin/plugins"); @@ -44,8 +56,17 @@ function load(name: string): string { return stripComments(readFileSync(join(DIR, name), "utf8")); } +/** Every route file in the directory, so a new one is scanned the day it lands. */ +const ALL_ROUTES = readdirSync(DIR) + .filter((name) => name.endsWith(".tsx")) + .sort(); + const VOCAB_CALLERS: Record = { - "index.tsx": ["moreAppsRow", "moreAppsSectionDescription"], + "index.tsx": [ + "moreAppsRow", + "moreAppsSectionDescription", + "providerVocabulary", + ], "$key.tsx": [ "brokeredConnectionDescription", "brokeredReach", @@ -53,23 +74,25 @@ const VOCAB_CALLERS: Record = { "brokeredProvenance", ], "composio.tsx": ["directoryRouteDecision"], + "arcade.tsx": ["directoryRouteDecision"], }; -const NO_VENDOR_LITERAL = ["index.tsx", "$key.tsx"]; +const NO_VENDOR_LITERAL = ["index.tsx", "$key.tsx", "composio.tsx"]; -test("the provider-neutral plugins pages carry no Composio literal", () => { - expect(NO_VENDOR_LITERAL.length).toBe(2); +test("the plugins routes carry no Composio literal", () => { + // composio.tsx is no longer exempt; $key_.bots and $key_.tools are vendor-blind already. + expect(NO_VENDOR_LITERAL.length).toBe(3); const offenders: string[] = []; for (const name of NO_VENDOR_LITERAL) { const text = load(name); - expect(text.length).toBeGreaterThan(1000); + expect(text.length).toBeGreaterThan(500); text.split("\n").forEach((line, i) => { // Capitalised AND all-caps. `COMPOSIO_API_KEY` is a user-visible string 1.8 moved // into the vocabulary (`envVar`), rendered as "Set COMPOSIO_API_KEY on this // deployment." — it must read ARCADE_API_KEY on an Arcade deployment, and an // env-var name is exactly the string an editor inlines as a constant. NOT - // case-insensitive: `composioConfigured` and the `/admin/plugins/composio` route - // path are legitimate and must stay legal. + // case-insensitive: `composioConfigured` and the `/_authed/admin/plugins/composio` + // route id are legitimate and must stay legal. if (/Composio|COMPOSIO/.test(line)) offenders.push(`${name}:${i + 1}: ${line.trim()}`); }); @@ -77,8 +100,25 @@ test("the provider-neutral plugins pages carry no Composio literal", () => { expect(offenders).toEqual([]); }); +test("no plugins route hard-codes a directory path", () => { + expect(ALL_ROUTES.length).toBeGreaterThanOrEqual(6); + expect(ALL_ROUTES).toContain("arcade.tsx"); + expect(ALL_ROUTES).toContain("composio.tsx"); + const offenders: string[] = []; + for (const name of ALL_ROUTES) { + const text = load(name); + expect(text.length).toBeGreaterThan(500); + text.split("\n").forEach((line, i) => { + // The route's own file id carries `_authed` and is not somewhere anybody navigates. + if (/(? { - expect(Object.keys(VOCAB_CALLERS).length).toBe(3); + expect(Object.keys(VOCAB_CALLERS).length).toBe(4); const missing: string[] = []; for (const [name, fns] of Object.entries(VOCAB_CALLERS)) { const text = load(name); diff --git a/app/tests/arcade-admin-plugins.test.tsx b/app/tests/arcade-admin-plugins.test.tsx index 79cfe814a..8915e1a4e 100644 --- a/app/tests/arcade-admin-plugins.test.tsx +++ b/app/tests/arcade-admin-plugins.test.tsx @@ -43,6 +43,11 @@ test("a Composio deployment keeps today's exact wording", () => { directoryLabel: "Browse Composio", envVar: "COMPOSIO_API_KEY", directoryPath: "/admin/plugins/composio", + // Composio's configured-branch sentence, verbatim: it used to be a literal inside a + // `provider === "composio"` branch in `moreAppsRow`, and moving it here is what let that + // branch go. + directoryDescription: + "A few hundred apps, reached as whoever is asking. Each person connects their own account.", }); expect(moreAppsSectionDescription("composio")).toBe( "A directory this deployment has not reviewed, reached through Composio.", @@ -77,7 +82,35 @@ test("the vocabulary follows the provider rather than being hard-coded", () => { expect(moreAppsRow("arcade", false).description).toContain("ARCADE_API_KEY"); expect(brokeredReach("arcade").badge).toBe("Through Arcade"); expect(brokeredDisconnectNote("arcade")).toContain("Arcade"); - expect(moreAppsRow("arcade", true).linked).toBe(false); +}); + +/* + * THE ROW GOES SOMEWHERE ON BOTH DEPLOYMENTS. It used to link only on Composio, because only + * Composio had a directory screen — so on an Arcade deployment the one route that draws the D8 + * coverage warning and the D32 breadth line was unreachable, and two measured warnings rendered + * nowhere. `linked` now follows the configuration and never the vendor. + */ +test("a configured deployment links to its own directory, whichever provider it is", () => { + expect(moreAppsRow("arcade", true)).toEqual({ + title: "Browse Arcade", + // Arcade's own sentence: it claims no app count, because nobody here has counted Arcade's. + description: + "A directory this deployment has not reviewed, reached as whoever is asking. Each person connects their own account.", + linked: true, + }); + expect(moreAppsRow("composio", true).linked).toBe(true); + // And an unconfigured row still goes nowhere, because there is nowhere to go until the key is set. + expect(moreAppsRow("arcade", false).linked).toBe(false); + expect(moreAppsRow("composio", false).linked).toBe(false); +}); + +test("each provider's directory path is its own route", () => { + expect(providerVocabulary("composio").directoryPath).toBe( + "/admin/plugins/composio", + ); + expect(providerVocabulary("arcade").directoryPath).toBe( + "/admin/plugins/arcade", + ); }); test("brokered-ness is read off the row and needs no provider", () => { diff --git a/app/tests/arcade-breadth.test.tsx b/app/tests/arcade-breadth.test.tsx index ad8b3e0a7..da7a6b69e 100644 --- a/app/tests/arcade-breadth.test.tsx +++ b/app/tests/arcade-breadth.test.tsx @@ -2,10 +2,8 @@ import { expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import fixture from "../../server/tests/fixtures/arcade-catalogue.json"; import type { ComposioApp } from "@/lib/plugins/queries"; -import { - BreadthNote, - consentBreadth, -} from "@/routes/_authed/admin/plugins/composio"; +import { BreadthNote } from "@/components/plugins/broker-directory"; +import { consentBreadth } from "@/lib/plugins/enablement-notes"; /** * The D32 consent-breadth line on the enablement screen. Derived from the three measured counts and @@ -49,10 +47,19 @@ function app(overrides: Partial = {}): ComposioApp { }; } -test("Apollo (no permissions) says approving any action approves all of them", () => { +/* + * THE SINGULAR OF THE NO-PERMISSION CASE, which is the only branch that had none while its siblings + * all did. Apollo is the fixture's one `permissionCount: 0` toolkit and it has exactly one action, + * so the sentence that shipped read "approving any action in this app approves all 1 of them" — a + * grammatical error on the single shape a real catalogue actually produces. Pinned to the exact + * string, because `/approves/` is what let it through. + */ +test("Apollo (no permissions, one action) says the app is the action", () => { const apollo = fromFixture("Apollo"); expect([apollo.permissionCount, apollo.actionCount]).toEqual([0, 1]); - expect(consentBreadth(apollo)).toMatch(/approves/); + expect(consentBreadth(apollo)).toBe( + "This app has a single action, and approving it approves the app.", + ); }); test("the plural wholly-unscoped case says all N of them", () => { @@ -128,6 +135,7 @@ test("it renders on the enablement step, before enabling", () => { test("no copy promises confinement", () => { const shapes = [ + app({ actionCount: 1, permissionCount: 0, scopedActionCount: 0 }), app({ actionCount: 3, permissionCount: 0, scopedActionCount: 0 }), app({ actionCount: 3, permissionCount: 2, scopedActionCount: 3 }), app({ actionCount: 3, permissionCount: 2, scopedActionCount: 1 }), diff --git a/app/tests/arcade-coverage.test.tsx b/app/tests/arcade-coverage.test.tsx index d72c1a544..2bb809044 100644 --- a/app/tests/arcade-coverage.test.tsx +++ b/app/tests/arcade-coverage.test.tsx @@ -2,10 +2,8 @@ import { expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import fixture from "../../server/tests/fixtures/arcade-catalogue.json"; import type { ComposioApp } from "@/lib/plugins/queries"; -import { - CoverageNote, - coverageWarning, -} from "@/routes/_authed/admin/plugins/composio"; +import { CoverageNote } from "@/components/plugins/broker-directory"; +import { coverageWarning } from "@/lib/plugins/enablement-notes"; /** * The D8 coverage warning on the enablement screen. Driven by `classifiedCount` against diff --git a/app/tests/arcade-directory-screen.test.tsx b/app/tests/arcade-directory-screen.test.tsx new file mode 100644 index 000000000..c0ca6164f --- /dev/null +++ b/app/tests/arcade-directory-screen.test.tsx @@ -0,0 +1,197 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import fixture from "../../server/tests/fixtures/arcade-catalogue.json"; +import { Route as ArcadeRoute } from "@/routes/_authed/admin/plugins/arcade"; + +/** + * THE MEASURED LINES, ON A SCREEN SOMEBODY CAN REACH. + * + * The coverage warning and the consent-breadth line were computed, unit-tested and rendered by + * nothing: they live on the directory row, and the only directory route redirected away on every + * Arcade deployment while the only provider that publishes the counts is Arcade. A warning computed + * and never shown is the same as no warning, and a feature nobody can navigate to is not shipped. + * + * So this renders the ARCADE route against an Arcade payload and reads the two sentences out of the + * markup. Nothing here imports a pure function: a passing pure function is exactly what this defect + * already had. + * + * The app under test is `AirtableApi`, folded out of `server/tests/fixtures/arcade-catalogue.json` + * the way `listApps` folds it — 2 actions, 0 classified, 1 distinct scope set over 1 scoped action. + * The fixture is evidence of what Arcade returns and is read, never appended to. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +type FixtureTool = { + toolkit: { name: string }; + metadata?: { behavior?: unknown }; + requirements?: { authorization?: { oauth2?: { scopes?: string[] } } }; +}; + +/** `AirtableApi` as the SERVER measures it, out of the recorded catalogue. */ +function airtableFromFixture() { + const tools = (Object.values(fixture.tools) as FixtureTool[]).filter( + (tool) => tool.toolkit.name === "AirtableApi", + ); + const sets = tools + .map((tool) => tool.requirements?.authorization?.oauth2?.scopes ?? []) + .filter((scopes) => scopes.length > 0); + return { + slug: "airtableapi", + name: "AirtableApi", + description: "Airtable, generated.", + logo: null, + categories: [], + actionCount: tools.length, + classifiedCount: tools.filter((tool) => tool.metadata?.behavior).length, + permissionCount: new Set(sets.map((s) => [...s].sort().join(" "))).size, + scopedActionCount: sets.length, + enabled: false, + }; +} + +/* + * Capture and restore of the exported `Route` singleton, verbatim from + * `composio-route-plugins-error.test.tsx` and for the reason recorded there: `.update()` merges into + * the live object and nothing re-runs `init()` on a replay. + */ +const originalOptions = { ...ArcadeRoute.options }; +afterEach(() => { + Object.assign(ArcadeRoute.options, originalOptions); +}); + +function renderDirectory() { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const pluginsRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () =>

the plugins index

, + }); + const wired = ( + ArcadeRoute as unknown as { + update: (options: unknown) => typeof ArcadeRoute; + } + ).update({ + id: "/plugins/arcade", + path: "/plugins/arcade", + getParentRoute: () => adminRoute, + }); + const tree = rootRoute.addChildren([ + authedRoute.addChildren([adminRoute.addChildren([pluginsRoute, wired])]), + ]); + const router = createRouter({ + routeTree: tree, + history: createMemoryHistory({ initialEntries: ["/admin/plugins/arcade"] }), + }); + return render( + + + , + ); +} + +/** A stub answering the page read with `provider`, and the directory with one app. */ +function stubFetch(provider: string | undefined, apps: unknown[]) { + global.fetch = (async (input: RequestInfo | URL) => { + const path = String(input).split("?")[0] ?? ""; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + if (path === "/api/plugins/composio/apps") return json({ apps }); + if (path === "/api/plugins") { + return json({ + catalogue: [], + servers: [], + skills: [], + botsMayCallBack: false, + redirectUri: null, + composioConfigured: true, + provider, + }); + } + return json({ connections: [] }); + }) as unknown as typeof fetch; +} + +test("the Arcade directory renders the coverage warning and the breadth line on a measured app", async () => { + stubFetch("arcade", [airtableFromFixture()]); + const view = renderDirectory(); + + const row = await view.findByTestId("directory-airtableapi"); + + // Not pending, not a redirect: the screen itself. + expect(view.queryByText("the plugins index")).toBeNull(); + expect(view.getByText("Browse Arcade")).toBeTruthy(); + + await waitFor(() => { + expect(row.textContent).toContain( + "This provider has not said which of its actions only read, so every action is treated as a destructive write and every call will ask for approval.", + ); + }); + expect(row.textContent).toContain( + "This app uses 1 separate permission across 2 actions. A further 1 requests no permission of its own and becomes callable with any approval here.", + ); +}); + +test("the Arcade directory redirects on a Composio deployment", async () => { + stubFetch("composio", []); + const view = renderDirectory(); + + await waitFor(() => { + expect(view.getByText("the plugins index")).toBeTruthy(); + }); + expect(view.queryByLabelText("Search Arcade apps")).toBeNull(); +}); + +test("the Arcade directory draws nothing until the page names a provider", async () => { + let release: () => void = () => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + global.fetch = (async (input: RequestInfo | URL) => { + if (String(input).split("?")[0] === "/api/plugins") await held; + return new Response(JSON.stringify({ apps: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const view = renderDirectory(); + expect(view.queryByLabelText("Search Arcade apps")).toBeNull(); + expect(view.queryByText("the plugins index")).toBeNull(); + expect(view.queryByRole("alert")).toBeNull(); + release(); +}); diff --git a/app/tests/composio-picker-add-error.test.tsx b/app/tests/composio-picker-add-error.test.tsx index 16b38f6f2..3eac88d81 100644 --- a/app/tests/composio-picker-add-error.test.tsx +++ b/app/tests/composio-picker-add-error.test.tsx @@ -176,8 +176,9 @@ test("an add that fails while a second one is pressed still says what went wrong const view = renderPicker(); // Both rows, in the order the screen sorts them: Gmail then Slack. - const slackRow = await view.findByTestId("composio-slack"); - const gmailRow = await view.findByTestId("composio-gmail"); + // `directory-…`, not `composio-…`: one provider-blind screen draws both brokers' directories. + const slackRow = await view.findByTestId("directory-slack"); + const gmailRow = await view.findByTestId("directory-gmail"); // The press that will fail, held open by the stub. await userEvent.click( diff --git a/app/tests/composio-picker.test.tsx b/app/tests/composio-picker.test.tsx index 05a8f97c8..af85edd7b 100644 --- a/app/tests/composio-picker.test.tsx +++ b/app/tests/composio-picker.test.tsx @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import type { ComposioApp } from "@/lib/plugins/queries"; -import { matchingApps } from "@/routes/_authed/admin/plugins/composio"; +import { matchingApps } from "@/lib/plugins/enablement-notes"; /** * What the Composio picker lists, decided without drawing anything. @@ -10,10 +10,10 @@ import { matchingApps } from "@/routes/_authed/admin/plugins/composio"; * decision actually rests on — whether the app is already here, and how much it brings with it — * arrive at the row intact. * - * A `.tsx` file because it imports a route module, which is JSX; the precedent is - * `agent-roster-error.test.tsx`. Nothing here renders, though — `matchingApps` is exported as a - * function rather than left inline in the map for exactly that reason, so the ordering and the - * already-added marker can be asserted without a DOM, a router or a query client. + * Nothing here renders — `matchingApps` is exported as a function rather than left inline in the + * map for exactly that reason, so the ordering and the already-added marker can be asserted without + * a DOM, a router or a query client. It now lives in `lib/plugins/enablement-notes.ts`, which is a + * plain `.ts` module for the same reason. */ /** A minimal but complete `ComposioApp`, overridable per case. */ From 55006e951613247704165126ee27fe373f67396e Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:28:27 +0200 Subject: [PATCH 070/232] fix(arcade): name the active broker in the directory read's own fallback The query layer's fallback for a directory read that fails with no server message spelled Composio's name, so an Arcade deployment could still print it (D11). The sentence now comes from providerVocabulary via directoryReadFailure, and composioAppsQueryOptions takes the provider. broker-directory.tsx is touched only to pass the provider it already holds and to use the same helper for its own fallback. Composio's sentence is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/plugins/broker-directory.tsx | 5 +-- app/src/lib/plugins/provider-vocabulary.ts | 10 ++++++ app/src/lib/plugins/queries.ts | 13 ++++++-- app/tests/directory-read-fallback.test.ts | 32 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 app/tests/directory-read-fallback.test.ts diff --git a/app/src/components/plugins/broker-directory.tsx b/app/src/components/plugins/broker-directory.tsx index 5ac2cfe72..b791a5a81 100644 --- a/app/src/components/plugins/broker-directory.tsx +++ b/app/src/components/plugins/broker-directory.tsx @@ -28,6 +28,7 @@ import { invalidatePlugins, } from "@/lib/plugins/mutations"; import { + directoryReadFailure, type PluginsProvider, providerVocabulary, } from "@/lib/plugins/provider-vocabulary"; @@ -69,7 +70,7 @@ export function BrokerDirectory({ provider }: { provider: PluginsProvider }) { return () => clearTimeout(timer); }, [search]); - const apps = useQuery(composioAppsQueryOptions(query)); + const apps = useQuery(composioAppsQueryOptions(query, provider)); /* * WHAT A FAILED ADD SAID, KEPT WHERE A SECOND ADD CANNOT ERASE IT. * @@ -154,7 +155,7 @@ export function BrokerDirectory({ provider }: { provider: PluginsProvider }) { * the single thing an operator who reached this URL needs to read; the derived line * stays as the fallback for a failure that arrived carrying no message of its own. */} - {apps.error.message || `${name}'s app directory could not be read.`} + {apps.error.message || directoryReadFailure(provider)}

) : listed.length === 0 ? ( diff --git a/app/src/lib/plugins/provider-vocabulary.ts b/app/src/lib/plugins/provider-vocabulary.ts index bf47b74b8..902452891 100644 --- a/app/src/lib/plugins/provider-vocabulary.ts +++ b/app/src/lib/plugins/provider-vocabulary.ts @@ -70,6 +70,16 @@ export function providerVocabulary( return VOCABULARY[provider]; } +/** + * What a failed directory read says when the server sent no message of its own. + * + * ONE SENTENCE FOR BOTH THE QUERY AND THE SCREEN, so the two layers cannot drift: the query layer's + * fallback once spelled a vendor on its own and printed Composio's name on an Arcade deployment. + */ +export function directoryReadFailure(provider: PluginsProvider): string { + return `${VOCABULARY[provider].name}'s app directory could not be read.`; +} + /** The "More apps" section's own sentence. */ export function moreAppsSectionDescription(provider: PluginsProvider): string { return `A directory this deployment has not reviewed, reached through ${VOCABULARY[provider].name}.`; diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index 6de6e061f..f552eb2a1 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,5 +1,9 @@ import { queryOptions } from "@tanstack/react-query"; import { client } from "@/lib/client"; +import { + directoryReadFailure, + type PluginsProvider, +} from "./provider-vocabulary"; /** * Re-exported, not re-declared. @@ -414,18 +418,21 @@ export function pluginsPageQueryOptions() { } /** - * Composio's app directory, narrowed by a search term. + * The active broker's app directory, narrowed by a search term. * * The term goes to our own endpoint because the vendor's client drops a search parameter and * answers with an unfiltered page, so filtering has to happen somewhere that admits to doing it. */ -export function composioAppsQueryOptions(query: string) { +export function composioAppsQueryOptions( + query: string, + provider: PluginsProvider, +) { return queryOptions({ queryKey: pluginKeys.composioApps(query), queryFn: async (): Promise<{ apps: ComposioApp[] }> => { const response = await client( `/api/plugins/composio/apps?q=${encodeURIComponent(query)}`, - { fallback: "Composio's app directory could not be read." }, + { fallback: directoryReadFailure(provider) }, ); return response.json(); }, diff --git a/app/tests/directory-read-fallback.test.ts b/app/tests/directory-read-fallback.test.ts new file mode 100644 index 000000000..854bf4306 --- /dev/null +++ b/app/tests/directory-read-fallback.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, test } from "bun:test"; +import { composioAppsQueryOptions } from "@/lib/plugins/queries"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Runs the directory query against a failure that carries no server message at all. */ +async function failureMessage(provider: "composio" | "arcade") { + globalThis.fetch = (async () => + new Response(null, { status: 500 })) as unknown as typeof fetch; + const options = composioAppsQueryOptions("", provider); + try { + await (options.queryFn as (ctx: unknown) => Promise)({}); + } catch (thrown) { + return (thrown as Error).message; + } + throw new Error("the query was expected to fail"); +} + +test("a Composio deployment's messageless directory failure is the existing sentence, unchanged", async () => { + expect(await failureMessage("composio")).toBe( + "Composio's app directory could not be read.", + ); +}); + +test("an Arcade deployment's messageless directory failure names Arcade and never Composio", async () => { + const message = await failureMessage("arcade"); + expect(message).toBe("Arcade's app directory could not be read."); + expect(message).not.toMatch(/composio/i); +}); From b425b1d9d79cb3ee029b9ecb000d520d6f23f7c0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:32:11 +0200 Subject: [PATCH 071/232] feat(arcade): compute revocation siblings from the (vendor identity, provider_id) pair Adds the sibling computation only. It answers a fact and acts on nothing: no revocation, no deletion, no audit write. 9.2b owns the cascade. An Arcade connection is keyed (user_id, provider_id), and this deployment holds many vendor identities: each person's own plus one random identity per Shared app (mintDeploymentVendorUserId). Shared Gmail and shared Slack, both on Google, do NOT share a connection, so keying on provider_id alone would warn about apps that will not be touched (spec D26). PUBLISHED SIGNATURES (server/src/plugins/store.ts), consumed by 9.2b and 10.2: export type ConnectionFootprint = { app: string; // toolkit as the vendor spells it, compared exactly vendorUserId: string; providerId: string; // vendor provider_id, compared case-insensitively }; export function connectionSiblings( of: ConnectionFootprint, among: readonly ConnectionFootprint[], ): string[]; // sorted toolkits, never `of.app` // on PluginStore: siblingApps(input: { toolkit: string; account: AccountRef; providerIdOf: (toolkit: string) => Promise; }): Promise; // sibling toolkits; [] when none or toolkit unanswered providerIdOf is the caller's (the adapter reads provider_id off the vendor listing; the store keeps no copy). An app it answers null for is left out. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 83 ++++++++++++++++ server/tests/arcade-siblings.test.ts | 139 +++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 server/tests/arcade-siblings.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5c3e0b66f..51b3ab6a4 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1249,6 +1249,46 @@ export type PluginStoreOptions = { /** One in-flight Arcade authorization attempt, as the table stores it. */ export type PendingAuthorization = typeof pendingAuthorizations.$inferSelect; +/** One app's connection, as far as sibling-finding needs to know it. */ +export type ConnectionFootprint = { + /** The toolkit, spelled as the vendor spells it. Compared exactly, never case-folded. */ + app: string; + /** The identity the connection is held under at the vendor. */ + vendorUserId: string; + /** The vendor's own provider the connection is with (`google`), not the broker's name. */ + providerId: string; +}; + +/** + * The apps that share `of`'s connection, which are the apps sharing BOTH its vendor identity AND + * its `provider_id`. NEVER THE PROVIDER ALONE (spec D26). + * + * REASON. An Arcade connection is keyed `(user_id, provider_id)`, and this deployment holds MANY + * vendor identities: each person's own, PLUS ONE RANDOM IDENTITY PER SHARED APP, minted by + * `mintDeploymentVendorUserId`. So shared Gmail and shared Slack, both using Google, do NOT share + * a connection: their `user_id`s differ. A computation keyed on `provider_id` alone would warn + * about apps that WILL NOT BE TOUCHED, which trains people to dismiss the warning that matters. + * Silently ending two connections when somebody asked to end one is the failure to avoid. + * + * `provider_id` is compared case-insensitively, as the adapter's `connectionsFor` does; the app + * name is not, because Arcade toolkit case is never normalised. The answer never contains `of` + * itself, and is sorted so two callers see the same list. This function ACTS ON NOTHING. + */ +export function connectionSiblings( + of: ConnectionFootprint, + among: readonly ConnectionFootprint[], +): string[] { + const provider = of.providerId.toLowerCase(); + const found = new Set(); + for (const other of among) { + if (other.app === of.app) continue; + if (other.vendorUserId !== of.vendorUserId) continue; + if (other.providerId.toLowerCase() !== provider) continue; + found.add(other.app); + } + return [...found].sort(); +} + /** Raw `execute` rows are snake_case and carry timestamps as whatever the driver decoded. */ function pendingAuthorizationFromRow( row: Record, @@ -6015,6 +6055,49 @@ export function createPluginStore(options: PluginStoreOptions) { return { verifiedAt, wrote: true }; }, + /** + * The other apps this deployment holds a connection for that share THIS app's connection: the + * apps on the same vendor identity AND the same `provider_id`, never the provider alone. The + * reason is written out on {@link connectionSiblings}; this is that computation over the rows + * this deployment actually holds under the active provider. + * + * `providerIdOf` IS PASSED IN because `provider_id` is the vendor's fact, read off its own + * listing by the adapter, and this store keeps no copy of it. An app it cannot answer for + * (`null`) is left out rather than guessed at: claiming a sibling that is not one is the failure + * D26 exists to prevent. If the asking app itself is unanswered the list is empty. + * + * IT ANSWERS A FACT AND ACTS ON NOTHING: no revocation, no deletion, no audit write. Returns the + * sibling toolkits, sorted, never including `toolkit`. + */ + async siblingApps(input: { + toolkit: string; + account: AccountRef; + providerIdOf: (toolkit: string) => Promise; + }): Promise { + const providerId = await input.providerIdOf(input.toolkit); + if (!providerId) return []; + const held = await database + .select({ + app: brokeredConnections.app, + vendorUserId: brokeredConnections.vendorUserId, + }) + .from(brokeredConnections) + .where(eq(brokeredConnections.provider, brokerProvider)); + const footprints: ConnectionFootprint[] = []; + for (const row of held) { + const rowProvider = await input.providerIdOf(row.app); + if (rowProvider) footprints.push({ ...row, providerId: rowProvider }); + } + return connectionSiblings( + { + app: input.toolkit, + vendorUserId: input.account.vendorUserId, + providerId, + }, + footprints, + ); + }, + /** * Start an Arcade authorization attempt for one person: take the key if it is free, expired or * completed, and refuse if a LIVE attempt holds it. ONE STATEMENT. diff --git a/server/tests/arcade-siblings.test.ts b/server/tests/arcade-siblings.test.ts new file mode 100644 index 000000000..d4875bbe4 --- /dev/null +++ b/server/tests/arcade-siblings.test.ts @@ -0,0 +1,139 @@ +// server/tests/arcade-siblings.test.ts +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { brokeredConnections } from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { mintDeploymentVendorUserId } from "../src/plugins/shared-accounts"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const gmail = `Gmail-${suite}`; +const calendar = `GoogleCalendar-${suite}`; +const slack = `Slack-${suite}`; +const lonely = `Lonely-${suite}`; + +const broker: ConnectedAppBroker = { + grantIsLocal: false, + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: "ar_1", + }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme", +}; + +const store = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, +}); + +// Which vendor provider each app's connection is held at, as the adapter reads it off the vendor's +// own listing. Gmail and Calendar are Google; Slack is Slack. +const providerOf: Record = { + [gmail]: "google", + [calendar]: "google", + [slack]: "slack", + [lonely]: "google", +}; +const providerIdOf = async (toolkit: string) => providerOf[toolkit] ?? null; + +const seeded: string[] = []; +async function holdShared(app: string, vendorUserId: string) { + seeded.push(app); + await database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder: "deployment", + vendorUserId, + }); +} +async function holdPersonal(app: string, userId: string) { + seeded.push(app); + await database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder: "person", + userId, + vendorUserId: userId, + }); +} + +afterEach(async () => { + if (seeded.length) + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, seeded)); + seeded.length = 0; +}); + +describe("revocation siblings are keyed on (vendor identity, provider_id)", () => { + test("two Shared apps on the same provider with DIFFERENT minted identities are not siblings", async () => { + const gmailIdentity = mintDeploymentVendorUserId(`acme-${suite}`); + const calendarIdentity = mintDeploymentVendorUserId(`acme-${suite}`); + expect(gmailIdentity).not.toBe(calendarIdentity); + await holdShared(gmail, gmailIdentity); + await holdShared(calendar, calendarIdentity); + + expect( + await store.siblingApps({ + toolkit: gmail, + account: { holder: "deployment", vendorUserId: gmailIdentity }, + providerIdOf, + }), + ).toEqual([]); + }); + + test("two apps genuinely sharing the pair are siblings, and an app on another provider is not", async () => { + const userId = `person-${suite}`; + await holdPersonal(gmail, userId); + await holdPersonal(calendar, userId); + await holdPersonal(slack, userId); + + expect( + await store.siblingApps({ + toolkit: gmail, + account: { holder: "person", userId, vendorUserId: userId }, + providerIdOf, + }), + ).toEqual([calendar]); + }); + + test("an app with no co-tenant has an empty list, never itself", async () => { + const userId = `solo-${suite}`; + await holdPersonal(lonely, userId); + + expect( + await store.siblingApps({ + toolkit: lonely, + account: { holder: "person", userId, vendorUserId: userId }, + providerIdOf, + }), + ).toEqual([]); + }); +}); From 6d5c75328490e080d28464b562e7aa28b93d60e4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:37:32 +0200 Subject: [PATCH 072/232] test(arcade): scan every plugins route for the vendor literal; record the queries/vocabulary cycle invariant Rule 1 of the vocabulary wiring guard now scans ALL_ROUTES, so arcade.tsx is covered. A comment at queries.ts's provider-vocabulary import records why the cycle is safe. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/plugins/queries.ts | 10 ++++++++++ .../admin-plugins-vocabulary-wiring.test.ts | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index f552eb2a1..b4d7195ce 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,5 +1,15 @@ import { queryOptions } from "@tanstack/react-query"; import { client } from "@/lib/client"; +// This import closes a cycle: `./provider-vocabulary` imports `pluginsProvider` from this file. +// It is safe for one reason, and the reason is the rule. Both live bindings that cross it — +// `directoryReadFailure` here, `pluginsProvider` there — are `export function` declarations, +// which are fully initialised during module instantiation, so neither can be caught in the +// temporal dead zone whichever module evaluates first; and both are only ever called from inside +// a function body (a `queryFn`, `directoryRouteDecision`), long after both modules have finished +// evaluating. Nothing may call across this import at module scope. A +// `const X = directoryReadFailure("...")` at the top level of this file would reach `VOCABULARY`, +// a lexical `const` in the other module, mid-evaluation: it throws under one evaluation order and +// not the other, so it can pass every test and still fail in a production bundle. import { directoryReadFailure, type PluginsProvider, diff --git a/app/tests/admin-plugins-vocabulary-wiring.test.ts b/app/tests/admin-plugins-vocabulary-wiring.test.ts index 821642fa2..12f7b590d 100644 --- a/app/tests/admin-plugins-vocabulary-wiring.test.ts +++ b/app/tests/admin-plugins-vocabulary-wiring.test.ts @@ -24,6 +24,10 @@ * literal and the exemption has no reason left — and an exemption kept * after its reason has gone is an escape hatch waiting to be used a second * time. + * The rule scans EVERY route file (`ALL_ROUTES`, enumerated from the + * directory), not a hand-kept list: an un-scanned sibling is the same + * escape hatch by omission, and `arcade.tsx` — the likeliest place for + * a pasted "Composio" — would have been one. * 2. NO route file contains a hard-coded `/admin/plugins/composio` or * `/admin/plugins/arcade` navigation target. THIS IS THE LOWERCASE * SPELLING RULE (1) CANNOT SEE, and it is the one that stranded the @@ -77,11 +81,17 @@ const VOCAB_CALLERS: Record = { "arcade.tsx": ["directoryRouteDecision"], }; -const NO_VENDOR_LITERAL = ["index.tsx", "$key.tsx", "composio.tsx"]; +// Self-extending, like rule 2: a hand-kept list is an un-scanned sibling waiting to happen, and +// the Arcade route is the likeliest place for a "Composio" literal to be pasted into. +const NO_VENDOR_LITERAL = ALL_ROUTES; test("the plugins routes carry no Composio literal", () => { - // composio.tsx is no longer exempt; $key_.bots and $key_.tools are vendor-blind already. - expect(NO_VENDOR_LITERAL.length).toBe(3); + // Every route file in the directory is scanned (no exemptions; arcade.tsx and the $key_.* + // children included). The floor and the two named files keep the scan from passing on an + // empty or truncated list. + expect(NO_VENDOR_LITERAL.length).toBeGreaterThanOrEqual(6); + expect(NO_VENDOR_LITERAL).toContain("arcade.tsx"); + expect(NO_VENDOR_LITERAL).toContain("composio.tsx"); const offenders: string[] = []; for (const name of NO_VENDOR_LITERAL) { const text = load(name); From e14e688de5a8e0a3a0fb9eac7b9297a39138fc1c Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:48:42 +0200 Subject: [PATCH 073/232] feat(arcade): put the toolkit's auth provider on the seam, read off the listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provider_id` is the AUTH-PROVIDER id, not the toolkit name: one Google connection serves `Gmail` and `GoogleCalendar` both, and a measured refusal envelope carries an operator-named `"provider_id": "cpk-poc"`. It coincides with the toolkit slug only where an operator named it that way — which `Github`, `Asana` and `Attio` all happen to do, and those are every toolkit in this tree's fixtures and stubs, so the coincidence is invisible to the tests that would otherwise catch a derivation. `ConnectedAppBroker.authProviderOf` is therefore a required top-level member both providers implement. Arcade reads `requirements.authorization.provider_id` off the FIRST page of `tools.list({ toolkit })` — no new vendor call and no paging, since every tool in a toolkit shares its auth provider — collects the distinct non-null ids and answers `null` on anything but exactly one, so a toolkit that disagrees with itself is unanswered rather than resolved to whichever action came first. There is no fallback to the toolkit name. Composio answers the toolkit itself, because its connections are per auth config and an auth config is per toolkit: a toolkit IS its own auth provider there and the sibling set is always empty, which is the truth about Composio rather than a degradation of it. It is named `authProviderOf` and never `providerIdOf`: `provider` in this codebase means the BROKER, and that collision was already paid for once when an audit key was renamed `provider` -> `authProvider`. 9.2a's store method is corrected to match. `ConnectionFootprint.providerId` becomes `authProvider`; `connectionSiblings` is otherwise verbatim. `siblingApps` drops its caller-supplied resolver and calls the broker the store was built with, so there is one answer by construction rather than a question each caller had to answer identically. Its scan is pre-filtered on `vendor_user_id` in the `WHERE` — semantically identical, since `connectionSiblings` discards exactly those rows, and it turns a vendor call per brokered app of the whole deployment into one per app on the asking identity, on a screen somebody is waiting on. Every `ConnectedAppBroker` literal under `server/tests` declares the new member, since `server/tests` is outside `tsc` and an omission would surface only as an unhandled error between tests. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 48 ++++++ server/src/plugins/broker.ts | 29 ++++ server/src/plugins/composio-adapter.ts | 18 +++ server/src/plugins/store.ts | 61 +++++--- server/tests/account-mode.integration.test.ts | 2 + .../arcade-attempt-store.integration.test.ts | 2 + server/tests/arcade-auth-provider.test.ts | 106 +++++++++++++ server/tests/arcade-authorize-cancel.test.ts | 4 + server/tests/arcade-authorize-route.test.ts | 4 + ...completion-transaction.integration.test.ts | 2 + server/tests/arcade-siblings.test.ts | 144 ++++++++++++++++-- server/tests/arcade-verify.test.ts | 2 + .../brokered-app-provider.integration.test.ts | 2 + server/tests/composio-adapter.test.ts | 30 +++- server/tests/composio-broker-seam.test.ts | 22 +++ .../tests/composio-connection-kinds.test.ts | 2 + server/tests/composio-connections.test.ts | 2 + server/tests/composio-grants-for.test.ts | 2 + server/tests/gate-grant-is-local.test.ts | 2 + .../mcp-grant-removal.integration.test.ts | 2 + server/tests/plugin-store.integration.test.ts | 4 + .../remote-bot-handoff.integration.test.ts | 2 + server/tests/routes-arcade-urls.test.ts | 2 + server/tests/shared-brokered-routes.test.ts | 2 + .../shared-brokered-store.integration.test.ts | 2 + .../shared-gated-mode.integration.test.ts | 2 + .../tests/shared-wiring.integration.test.ts | 2 + server/tests/store-scheme-sites.test.ts | 2 + 28 files changed, 470 insertions(+), 34 deletions(-) create mode 100644 server/tests/arcade-auth-provider.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 2b15fa956..93a532c68 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -363,6 +363,51 @@ async function grantsFor( return items.flatMap(grantStateOf); } +/** + * The auth provider a toolkit's connections are held under, read off the LISTING ITSELF. + * + * READ, NEVER DERIVED FROM THE TOOLKIT NAME. `requirements.authorization.provider_id` is the field + * the fixture already carries beside the auth-config `id`, and it names an auth-provider + * configuration rather than a toolkit — one Google provider serves `Gmail` and `GoogleCalendar` + * both. Reading it is correct under BOTH readings of the field: if it did turn out to equal the + * toolkit slug, the listing would simply say so. Comparing it to the toolkit name is correct under + * only one, and would be invisible against every fixture in this tree, where `Github`, `Asana` and + * `Attio` all happen to be named after themselves. There is NO FALLBACK to the toolkit name. + * + * ONE PAGE AND NO PAGING, which is what makes this free: every tool in a toolkit shares its auth + * provider — that is what makes it a toolkit. `listApps` pages to the end because it is COUNTING + * actions; this is reading a per-toolkit fact off any of them. + * + * AND IT GUARDS RATHER THAN ASSUMES. The DISTINCT non-null provider ids on the page are collected + * and anything other than exactly one answers `null`, so a toolkit that disagrees with itself is + * UNANSWERED rather than silently resolved to whichever action came first. Case is carried through + * untouched, so two spellings of one provider count as two and are likewise unanswered; whether + * they are the same provider is the caller's comparison, and `connectionSiblings` folds case for + * the one caller that makes it. + * + * `requirements.met` IS NOT READ HERE EITHER, for `grantStateOf`'s reason: this reads a DIFFERENT + * field of the same `requirements.authorization` object. + */ +async function authProviderOf( + client: Arcade, + toolkit: string, +): Promise { + const page = await client.tools.list({ toolkit }); + const items = page?.items; + if (!Array.isArray(items)) { + throw new Error( + `Arcade did not answer with an action list for ${toolkit}, so the auth provider its connections are held under could not be read.`, + ); + } + const named = new Set(); + for (const item of items) { + const authorization = record(record(item)?.requirements)?.authorization; + const provider = textOf(record(authorization)?.provider_id); + if (provider) named.add(provider); + } + return named.size === 1 ? [...named][0] : null; +} + /** * The Arcade client, in the shape `createComposioClient` returns. `new Arcade({ apiKey })` opens no * socket and sends no request, so tests replace the SDK's PROTOTYPE methods before calling this. @@ -494,6 +539,9 @@ export function createArcadeClient(apiKey: string): { // person's full profile. grantsFor: ({ account, toolkit }) => grantsFor(client, account.vendorUserId, toolkit), + // Read off the listing's own `requirements.authorization.provider_id`, never derived from the + // toolkit name and never falling back to it. See `authProviderOf` above. + authProviderOf: (toolkit) => authProviderOf(client, toolkit), authorizations: { /** * Where one minted authorization has got to — THREE FIELDS READ OUT AND THE RESPONSE OBJECT diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 9826e0ed9..2833d1afd 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -498,6 +498,35 @@ export type ConnectedAppBroker = { * is copied across from a provider's listing; see that type. */ grantsFor(q: { toolkit: string; account: AccountRef }): Promise; + /** + * The VENDOR'S OWN AUTH PROVIDER a connection for this toolkit is held under, or null where the + * vendor does not say. + * + * IT IS NOT THE TOOLKIT NAME, AND THE COINCIDENCE IS NOT THE RULE. Arcade keys a connection on + * `(user_id, provider_id)`, and `provider_id` names an auth-provider CONFIGURATION rather than a + * toolkit: one Google connection serves `Gmail` and `GoogleCalendar` both, and a measured refusal + * envelope carries `"provider_id": "cpk-poc"`, which is an operator's name for a provider they + * registered. It equals the toolkit slug only where an operator named it that way — which + * `Github`, `Asana` and `Attio` all happen to do, and those are every toolkit in this tree's + * fixtures and stubs. So a derivation from the toolkit name, or a fallback to it where the vendor + * is silent, would pass every test here and be wrong about the deployment it was written for. + * `null` is the honest answer, and its callers leave such an app out rather than guess. + * + * NAMED `authProviderOf` AND NEVER `providerIdOf`. In this codebase `provider` means the BROKER — + * {@link BrokerProvider}, `brokered_connections.provider` — and that collision has already been + * paid for once, when an audit key `provider` was renamed `authProvider` because it meant the auth + * provider while a column fifty lines away meant the broker. + * + * TOP-LEVEL RATHER THAN ON {@link ConnectedAppBroker.authorizations}, because it is not a step in + * a flow and it is not a question only one provider can answer. Both answer it; see + * {@link ConnectedAppBroker.grantsFor} for the same line drawn one member along, and for why a + * DEGENERATE answer — a vendor whose connections are per toolkit, so a toolkit is its own auth + * provider and the shared set is always empty — is an answer rather than an absence. + * + * CASE IS CARRIED THROUGH UNTOUCHED. Whether two providers are the same provider is the caller's + * comparison to make, and Arcade's toolkit and action case is never normalised here. + */ + authProviderOf(toolkit: string): Promise; /** Every app the catalogue offers, which is what an administrator chooses from. */ listApps(): Promise; /** diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index b7f44e7fa..f9ed30b47 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -3064,6 +3064,24 @@ export function buildComposioClient( // return leg is an OAuth callback with state, handled in `oauth.ts` and never through this // seam. Declared rather than omitted; see {@link ConnectedAppBroker.authorizations}. authorizations: null, + /** + * The toolkit itself, WHICH IS AN ANSWER AND NOT A STUB. + * + * A Composio connection is made against an auth config, and an auth config is per toolkit. So + * there is no auth provider standing behind several toolkits here the way Arcade's `google` + * stands behind `Gmail` and `GoogleCalendar`: the toolkit IS its own auth provider, and the set + * of apps sharing a connection with it is consequently ALWAYS EMPTY. That is the truth about + * Composio rather than a degradation of it — {@link ConnectedAppBroker.grantsFor}'s "degenerate + * is not absent", one member along. + * + * SO IT DOES NOT THROW, and the reason is what a throw would cost the callers: every consumer + * of the sibling computation would need a presence test around this call, and the disconnect + * path would carry a provider branch — which is the vendor-name comparison this seam exists to + * keep out of permission and revocation code. + * + * No network: the answer is a property of how Composio is shaped, so nothing is asked. + */ + authProviderOf: async (toolkit) => toolkit, /** * What this person holds at this app, said once per action because that is what Composio knows. * diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 51b3ab6a4..3522ce5fd 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1255,13 +1255,20 @@ export type ConnectionFootprint = { app: string; /** The identity the connection is held under at the vendor. */ vendorUserId: string; - /** The vendor's own provider the connection is with (`google`), not the broker's name. */ - providerId: string; + /** + * The vendor's own AUTH PROVIDER the connection is held under (`google`), not the broker's name. + * + * NAMED `authProvider` AND NEVER `providerId`, because `provider` in this file means the BROKER — + * `brokered_connections.provider` is read a few lines below this one — and that collision has + * already been paid for once in an audit payload. See {@link ConnectedAppBroker.authProviderOf}, + * which is where this value comes from and which is likewise not the toolkit name. + */ + authProvider: string; }; /** * The apps that share `of`'s connection, which are the apps sharing BOTH its vendor identity AND - * its `provider_id`. NEVER THE PROVIDER ALONE (spec D26). + * its AUTH PROVIDER. NEVER THE PROVIDER ALONE (spec D26). * * REASON. An Arcade connection is keyed `(user_id, provider_id)`, and this deployment holds MANY * vendor identities: each person's own, PLUS ONE RANDOM IDENTITY PER SHARED APP, minted by @@ -1270,7 +1277,7 @@ export type ConnectionFootprint = { * about apps that WILL NOT BE TOUCHED, which trains people to dismiss the warning that matters. * Silently ending two connections when somebody asked to end one is the failure to avoid. * - * `provider_id` is compared case-insensitively, as the adapter's `connectionsFor` does; the app + * The auth provider is compared case-insensitively, as the adapter's `connectionsFor` does; the app * name is not, because Arcade toolkit case is never normalised. The answer never contains `of` * itself, and is sorted so two callers see the same list. This function ACTS ON NOTHING. */ @@ -1278,12 +1285,12 @@ export function connectionSiblings( of: ConnectionFootprint, among: readonly ConnectionFootprint[], ): string[] { - const provider = of.providerId.toLowerCase(); + const provider = of.authProvider.toLowerCase(); const found = new Set(); for (const other of among) { if (other.app === of.app) continue; if (other.vendorUserId !== of.vendorUserId) continue; - if (other.providerId.toLowerCase() !== provider) continue; + if (other.authProvider.toLowerCase() !== provider) continue; found.add(other.app); } return [...found].sort(); @@ -6057,14 +6064,27 @@ export function createPluginStore(options: PluginStoreOptions) { /** * The other apps this deployment holds a connection for that share THIS app's connection: the - * apps on the same vendor identity AND the same `provider_id`, never the provider alone. The + * apps on the same vendor identity AND the same AUTH PROVIDER, never the provider alone. The * reason is written out on {@link connectionSiblings}; this is that computation over the rows * this deployment actually holds under the active provider. * - * `providerIdOf` IS PASSED IN because `provider_id` is the vendor's fact, read off its own - * listing by the adapter, and this store keeps no copy of it. An app it cannot answer for - * (`null`) is left out rather than guessed at: claiming a sibling that is not one is the failure - * D26 exists to prevent. If the asking app itself is unanswered the list is empty. + * THE BROKER THIS STORE WAS BUILT WITH ANSWERS IT, AND NO CALLER SUPPLIES A RESOLVER. The auth + * provider is the vendor's fact and this store keeps no copy of it, so it is asked for — of + * {@link ConnectedAppBroker.authProviderOf}, which both providers implement. A resolver taken as + * a parameter would be a question every caller had to answer identically or see a different + * sibling set; with the store calling its own broker there is ONE answer by construction and + * nothing to keep in step. A deployment with no broker answers `[]` — a presence test on a + * declared value, and correct, because such a deployment holds no brokered rows at all. + * + * THE SCAN IS PRE-FILTERED ON THE ASKING IDENTITY, IN THE `WHERE`. Resolving the auth provider + * is a vendor call per app, on a screen somebody is waiting on; resolving it for every brokered + * row of the whole deployment and only then discarding the rows on other identities is unbounded + * in the one dimension that grows. `vendor_user_id` is a column, so the filter is free — and + * semantically identical, because {@link connectionSiblings} discards exactly those rows. + * + * An app the broker cannot answer for (`null`) is left out rather than guessed at: claiming a + * sibling that is not one is the failure D26 exists to prevent. If the asking app itself is + * unanswered the list is empty. * * IT ANSWERS A FACT AND ACTS ON NOTHING: no revocation, no deletion, no audit write. Returns the * sibling toolkits, sorted, never including `toolkit`. @@ -6072,27 +6092,32 @@ export function createPluginStore(options: PluginStoreOptions) { async siblingApps(input: { toolkit: string; account: AccountRef; - providerIdOf: (toolkit: string) => Promise; }): Promise { - const providerId = await input.providerIdOf(input.toolkit); - if (!providerId) return []; + if (!broker) return []; + const authProvider = await broker.authProviderOf(input.toolkit); + if (!authProvider) return []; const held = await database .select({ app: brokeredConnections.app, vendorUserId: brokeredConnections.vendorUserId, }) .from(brokeredConnections) - .where(eq(brokeredConnections.provider, brokerProvider)); + .where( + and( + eq(brokeredConnections.provider, brokerProvider), + eq(brokeredConnections.vendorUserId, input.account.vendorUserId), + ), + ); const footprints: ConnectionFootprint[] = []; for (const row of held) { - const rowProvider = await input.providerIdOf(row.app); - if (rowProvider) footprints.push({ ...row, providerId: rowProvider }); + const rowProvider = await broker.authProviderOf(row.app); + if (rowProvider) footprints.push({ ...row, authProvider: rowProvider }); } return connectionSiblings( { app: input.toolkit, vendorUserId: input.account.vendorUserId, - providerId, + authProvider, }, footprints, ); diff --git a/server/tests/account-mode.integration.test.ts b/server/tests/account-mode.integration.test.ts index fd9c233a7..01c79f5a1 100644 --- a/server/tests/account-mode.integration.test.ts +++ b/server/tests/account-mode.integration.test.ts @@ -35,6 +35,8 @@ const broker = { // This stub stands in for Composio, whose grant is held locally and which runs no // correlated authorization flow; see `ConnectedAppBroker.grantIsLocal` and `.authorizations`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, authorizations: null, grantsFor: async () => [], listApps: async () => [], diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 30c3e7d53..160db034b 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -17,6 +17,8 @@ const serverId = `arcade-attempt-store-${suite}`; const broker: ConnectedAppBroker = { // Arcade holds the grant at the vendor, not locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: false, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Arcade runs the correlated, pollable flow, so this stub DECLARES the sub-surface rather // than a null; nothing here reaches either member. See `ConnectedAppBroker.authorizations`. authorizations: { diff --git a/server/tests/arcade-auth-provider.test.ts b/server/tests/arcade-auth-provider.test.ts new file mode 100644 index 000000000..5a068703d --- /dev/null +++ b/server/tests/arcade-auth-provider.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import Arcade from "@arcadeai/arcadejs"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; + +/** + * `provider_id` IS THE AUTH PROVIDER AND NOT THE TOOLKIT, which is the whole of what this file + * exists to pin. One Google connection serves `Gmail` and `GoogleCalendar` both, and the live API + * refuses with an operator-named `"provider_id": "cpk-poc"` — a provider CONFIGURATION, not a + * toolkit. It coincides with the toolkit slug only when an operator names it that way, which + * `Github`, `Asana` and `Attio` all happen to do and which is every toolkit in this tree's + * fixtures. So the case that matters is constructed here rather than taken from the fixture: a + * toolkit whose provider is named DIFFERENTLY from it. + * + * Same prototype-stubbing pattern as `arcade-adapter.test.ts`, so nothing dials. + */ +type Methods = Record unknown>; +const toolsProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).tools, + ) as Methods; + +const restores: (() => void)[] = []; +function stub( + on: Methods, + name: string, + impl: (...args: unknown[]) => unknown, +) { + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} +afterEach(() => { + while (restores.length) restores.pop()?.(); +}); + +/** One listed action carrying the provider its toolkit's connections are held under. */ +const toolUnder = (toolkit: string, i: number, providerId: string | null) => ({ + qualified_name: `${toolkit}.A${i}`, + toolkit: { name: toolkit }, + requirements: { + authorization: { + id: `arcade-${i}`, + ...(providerId === null ? {} : { provider_id: providerId }), + oauth2: { scopes: [] }, + }, + }, +}); + +describe("Arcade authProviderOf", () => { + test("answers the listing's provider for a toolkit named differently from it", async () => { + const asked: unknown[] = []; + stub(toolsProto(), "list", (query: unknown) => { + asked.push(query); + return Promise.resolve({ + items: [ + toolUnder("Gmail", 1, "google"), + toolUnder("Gmail", 2, "google"), + ], + }); + }); + + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.authProviderOf("Gmail")).toBe("google"); + // ONE page and no paging: every tool in a toolkit shares its auth provider. + expect(asked).toHaveLength(1); + expect((asked[0] as { toolkit?: string }).toolkit).toBe("Gmail"); + }); + + test("carries the vendor's own case through untouched", async () => { + stub(toolsProto(), "list", () => + Promise.resolve({ items: [toolUnder("Gmail", 1, "Google")] }), + ); + + expect( + await createArcadeClient("never-dialled").broker.authProviderOf("Gmail"), + ).toBe("Google"); + }); + + test("answers null when the page names two distinct providers, never the first", async () => { + stub(toolsProto(), "list", () => + Promise.resolve({ + items: [ + toolUnder("Mixed", 1, "google"), + toolUnder("Mixed", 2, "slack"), + ], + }), + ); + + expect( + await createArcadeClient("never-dialled").broker.authProviderOf("Mixed"), + ).toBeNull(); + }); + + test("answers null — NEVER the toolkit name — when the listing does not say", async () => { + stub(toolsProto(), "list", () => + Promise.resolve({ items: [toolUnder("Gmail", 1, null)] }), + ); + + expect( + await createArcadeClient("never-dialled").broker.authProviderOf("Gmail"), + ).toBeNull(); + }); +}); diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index bb7af3ebd..8d8fd926b 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -35,6 +35,8 @@ let beforeMint: (() => Promise) | null = null; const arcadeBroker: ConnectedAppBroker = { grantIsLocal: false, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, authorizations: { status: async () => { throw new Error("nothing here polls an authorization"); @@ -63,6 +65,8 @@ const arcadeBroker: ConnectedAppBroker = { const composioBroker: ConnectedAppBroker = { ...arcadeBroker, grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, authorizations: null, authorize: async ({ account, toolkit, action }) => { mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index cad89c928..6d8aaea4f 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -37,6 +37,8 @@ let beforeMint: (() => Promise) | null = null; const arcadeBroker: ConnectedAppBroker = { grantIsLocal: false, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, authorizations: { status: async () => { throw new Error("nothing here polls an authorization"); @@ -65,6 +67,8 @@ const arcadeBroker: ConnectedAppBroker = { const composioBroker: ConnectedAppBroker = { ...arcadeBroker, grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, authorizations: null, authorize: async ({ account, toolkit, action }) => { mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index aa065bcce..fe43db73c 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -29,6 +29,8 @@ const serverId = `arcade-completion-${suite}`; const broker: ConnectedAppBroker = { // Arcade holds the grant at the vendor, not locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: false, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Arcade runs the correlated, pollable flow, so this stub DECLARES the sub-surface rather // than a null; nothing here reaches either member. See `ConnectedAppBroker.authorizations`. authorizations: { diff --git a/server/tests/arcade-siblings.test.ts b/server/tests/arcade-siblings.test.ts index d4875bbe4..1766db87e 100644 --- a/server/tests/arcade-siblings.test.ts +++ b/server/tests/arcade-siblings.test.ts @@ -6,8 +6,9 @@ import { createAuditStore } from "../src/audit"; import { createDatabase } from "../src/db/client"; import { brokeredConnections } from "../src/db/schema"; import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createComposioClient } from "../src/plugins/composio-adapter"; import { mintDeploymentVendorUserId } from "../src/plugins/shared-accounts"; -import { createPluginStore } from "../src/plugins/store"; +import { connectionSiblings, createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -17,6 +18,18 @@ const calendar = `GoogleCalendar-${suite}`; const slack = `Slack-${suite}`; const lonely = `Lonely-${suite}`; +// Which vendor auth provider each app's connection is held under, as the adapter reads it off the +// vendor's own listing. Gmail and Calendar are Google; Slack is Slack — and NONE of them is named +// after its toolkit, which is the fact the resolver must not be able to guess its way around. +const providerOf: Record = { + [gmail]: "google", + [calendar]: "google", + [slack]: "slack", + [lonely]: "google", +}; +/** Every toolkit the store asked about, in order. The pre-filter is what this pins. */ +const asked: string[] = []; + const broker: ConnectedAppBroker = { grantIsLocal: false, authorizations: { @@ -27,6 +40,10 @@ const broker: ConnectedAppBroker = { throw new Error("nothing here confirms a return leg"); }, }, + authProviderOf: async (toolkit) => { + asked.push(toolkit); + return providerOf[toolkit] ?? null; + }, grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", @@ -53,16 +70,6 @@ const store = createPluginStore({ broker, }); -// Which vendor provider each app's connection is held at, as the adapter reads it off the vendor's -// own listing. Gmail and Calendar are Google; Slack is Slack. -const providerOf: Record = { - [gmail]: "google", - [calendar]: "google", - [slack]: "slack", - [lonely]: "google", -}; -const providerIdOf = async (toolkit: string) => providerOf[toolkit] ?? null; - const seeded: string[] = []; async function holdShared(app: string, vendorUserId: string) { seeded.push(app); @@ -90,9 +97,53 @@ afterEach(async () => { .delete(brokeredConnections) .where(inArray(brokeredConnections.app, seeded)); seeded.length = 0; + asked.length = 0; +}); + +/** + * THE PAIR, ASSERTED ON THE PURE FUNCTION, WHICH IS WHERE IT IS NOW THE ONLY THING ASSERTING IT. + * + * `siblingApps` pre-filters its scan on the asking identity in SQL, so a footprint on another + * identity never reaches `connectionSiblings` through that door any more and a store-level test can + * no longer tell the identity check from the `WHERE`. The rule itself has not moved — the apps that + * share a connection are the apps sharing BOTH the vendor identity and the auth provider — so the + * proof moves here, where deleting the `vendorUserId` equality reddens it. + */ +describe("connectionSiblings keys on the pair and never the provider alone", () => { + test("two shared Google apps with different minted identities are not siblings", () => { + const gmailIdentity = mintDeploymentVendorUserId(`acme-${suite}`); + const calendarIdentity = mintDeploymentVendorUserId(`acme-${suite}`); + expect(gmailIdentity).not.toBe(calendarIdentity); + + expect( + connectionSiblings( + { app: gmail, vendorUserId: gmailIdentity, authProvider: "google" }, + [ + { app: gmail, vendorUserId: gmailIdentity, authProvider: "google" }, + { + app: calendar, + vendorUserId: calendarIdentity, + authProvider: "google", + }, + ], + ), + ).toEqual([]); + }); + + test("one identity on one provider is siblings, case-folded on the provider alone", () => { + expect( + connectionSiblings( + { app: gmail, vendorUserId: "u", authProvider: "google" }, + [ + { app: calendar, vendorUserId: "u", authProvider: "GOOGLE" }, + { app: slack, vendorUserId: "u", authProvider: "slack" }, + ], + ), + ).toEqual([calendar]); + }); }); -describe("revocation siblings are keyed on (vendor identity, provider_id)", () => { +describe("revocation siblings are keyed on (vendor identity, auth provider)", () => { test("two Shared apps on the same provider with DIFFERENT minted identities are not siblings", async () => { const gmailIdentity = mintDeploymentVendorUserId(`acme-${suite}`); const calendarIdentity = mintDeploymentVendorUserId(`acme-${suite}`); @@ -104,7 +155,6 @@ describe("revocation siblings are keyed on (vendor identity, provider_id)", () = await store.siblingApps({ toolkit: gmail, account: { holder: "deployment", vendorUserId: gmailIdentity }, - providerIdOf, }), ).toEqual([]); }); @@ -119,7 +169,6 @@ describe("revocation siblings are keyed on (vendor identity, provider_id)", () = await store.siblingApps({ toolkit: gmail, account: { holder: "person", userId, vendorUserId: userId }, - providerIdOf, }), ).toEqual([calendar]); }); @@ -132,7 +181,72 @@ describe("revocation siblings are keyed on (vendor identity, provider_id)", () = await store.siblingApps({ toolkit: lonely, account: { holder: "person", userId, vendorUserId: userId }, - providerIdOf, + }), + ).toEqual([]); + }); + + /** + * THE PRE-FILTER, AND A CALL COUNT IS THE ONLY THING THAT PINS IT. Resolving the auth provider is + * a VENDOR CALL per app, made on a screen somebody is waiting on (the Disconnect confirmation), + * and a scan that resolved every brokered row of the deployment and only then discarded the rows + * on other identities would be unbounded in the one dimension that grows. The answer is identical + * either way — `connectionSiblings` discards exactly those rows — so nothing but the count can + * tell the two implementations apart. + */ + test("resolves the auth provider ONLY for rows on the asking identity", async () => { + const mine = `mine-${suite}`; + const theirs = `theirs-${suite}`; + await holdPersonal(gmail, mine); + await holdPersonal(calendar, mine); + await holdPersonal(slack, theirs); + await holdShared(lonely, mintDeploymentVendorUserId(`acme-${suite}`)); + + expect( + await store.siblingApps({ + toolkit: gmail, + account: { holder: "person", userId: mine, vendorUserId: mine }, + }), + ).toEqual([calendar]); + + // The asking toolkit, plus its own two rows. Never `slack`, never `lonely`. + expect(asked.toSorted()).toEqual([calendar, gmail, gmail].toSorted()); + }); +}); + +/** + * COMPOSIO'S EMPTY ANSWER IS THE TRUTH ABOUT COMPOSIO. Its connections are per auth config and an + * auth config is per toolkit, so each app is its own auth provider and no two apps ever share one + * connection — however many apps one identity holds. Asserted through the store rather than only + * on the broker, because the empty list is what a disconnect screen will render. + */ +describe("siblingApps under Composio", () => { + const composioStore = createPluginStore({ + brokerProvider: "composio", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker: createComposioClient("never-dialled").broker, + }); + + test("an identity holding several apps has no siblings at any of them", async () => { + const userId = `composio-${suite}`; + for (const app of [gmail, calendar, slack]) { + seeded.push(app); + await database.insert(brokeredConnections).values({ + provider: "composio", + app, + holder: "person", + userId, + vendorUserId: userId, + }); + } + + expect( + await composioStore.siblingApps({ + toolkit: gmail, + account: { holder: "person", userId, vendorUserId: userId }, }), ).toEqual([]); }); diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 04fb94b87..0c4e2dd19 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -56,6 +56,8 @@ let confirmAnswer: () => Promise< */ const broker: ConnectedAppBroker = { grantIsLocal: false, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, authorizations: { status: async () => { throw new Error("the refusal half polls no authorization"); diff --git a/server/tests/brokered-app-provider.integration.test.ts b/server/tests/brokered-app-provider.integration.test.ts index a5577982f..feb51b733 100644 --- a/server/tests/brokered-app-provider.integration.test.ts +++ b/server/tests/brokered-app-provider.integration.test.ts @@ -51,6 +51,8 @@ function storeFor( const broker: ComposioBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 25fbbafe5..761c0aa43 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -3123,6 +3123,20 @@ describe("what a vendor failure becomes on its way out of the seam", () => { GENUINE_OUTAGES.map((outage) => `${outage.method}/${outage.kind}`), ); + /** + * The seam methods that REACH NO VENDOR, named here rather than quietly filtered. + * + * `authProviderOf` answers from the shape of Composio itself — a connection is made against an + * auth config and an auth config is per toolkit, so a toolkit IS its own auth provider — and it + * therefore asks nobody anything. A row in the table below would have to invent a vendor call for + * it to break on, which would assert something about the fake and nothing about the method. + * + * NAMING IT IS WHAT KEEPS THE COMPLETENESS TEST HONEST IN BOTH DIRECTIONS: the next method added + * to this seam is covered by the table or appears on this list on purpose, and the test below + * then checks that what is on this list genuinely dials nothing. + */ + const DIALS_NOTHING = new Set(["authProviderOf"]); + /** * Every method of the seam, with the vendor call that decides its answer aimed at the throw. * @@ -3318,7 +3332,9 @@ describe("what a vendor failure becomes on its way out of the seam", () => { .filter(([, member]) => typeof member === "function") .map(([name]) => name); expect( - [...methodsOf(seam.broker), ...methodsOf(seam.actions)].sort(), + [...methodsOf(seam.broker), ...methodsOf(seam.actions)] + .filter((name) => !DIALS_NOTHING.has(name as SeamMethod)) + .sort(), ).toEqual(SEAM_CASES.map((seamCase) => seamCase.method).sort()); // Whatever is not a method must be acknowledged by name, so a new non-function member of the // broker is a decision somebody makes rather than a thing the filter above silently drops. @@ -3329,6 +3345,18 @@ describe("what a vendor failure becomes on its way out of the seam", () => { ).toEqual(["grantIsLocal", "authorizations"]); }); + /** + * The other direction of {@link DIALS_NOTHING}: what is excused from the table must genuinely + * have nothing to fail at. The vendor here has no methods at all, so any call out of this seam + * would raise rather than answer. + */ + test("a method excused from the table reaches no vendor at all", async () => { + const { broker } = buildComposioClient(fakeVendor({})); + + expect([...DIALS_NOTHING]).toEqual(["authProviderOf"]); + expect(await broker.authProviderOf("gmail")).toBe("gmail"); + }); + for (const seamCase of SEAM_CASES) { for (const thrown of THROWN) { const allowed = UNEXPLAINABLE.has(`${seamCase.method}/${thrown.kind}`); diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index 4e154c2bb..6a8577b89 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -198,3 +198,25 @@ describe("grantIsLocal", () => { } }); }); + +/** + * `authProviderOf` is the third declared value on this seam, and Composio's answer is a REAL one. + * + * A Composio connection is made against an auth config, and an auth config is per toolkit — so the + * toolkit IS its own auth provider there, and the set of apps sharing a connection with it is + * consequently always empty. That is the truth about Composio rather than a degradation of it, the + * same "degenerate is not absent" `grantsFor` already turns on. A throw would be the alternative, + * and it would force a presence test into every consumer and a provider branch into a disconnect + * path. + */ +describe("authProviderOf", () => { + test("Composio answers the toolkit itself, dialling nothing", async () => { + const { broker } = createComposioClient("never-dialled"); + + expect(await broker.authProviderOf("gmail")).toBe("gmail"); + // Case carried through untouched, as every toolkit spelling on this seam is. + expect(await broker.authProviderOf("GoogleCalendar")).toBe( + "GoogleCalendar", + ); + }); +}); diff --git a/server/tests/composio-connection-kinds.test.ts b/server/tests/composio-connection-kinds.test.ts index b5229f7dc..b9d8f5981 100644 --- a/server/tests/composio-connection-kinds.test.ts +++ b/server/tests/composio-connection-kinds.test.ts @@ -661,6 +661,8 @@ let brokerAsks: string[] = []; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/composio-connections.test.ts b/server/tests/composio-connections.test.ts index c1f8a383d..94b87c4d1 100644 --- a/server/tests/composio-connections.test.ts +++ b/server/tests/composio-connections.test.ts @@ -682,6 +682,8 @@ let configWasStanding = false; const broker: ComposioBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/composio-grants-for.test.ts b/server/tests/composio-grants-for.test.ts index 6c3b07713..05709c9ee 100644 --- a/server/tests/composio-grants-for.test.ts +++ b/server/tests/composio-grants-for.test.ts @@ -216,6 +216,8 @@ describe("the declared sub-surface", () => { test("a broker literal omitting authorizations does not compile", () => { const members = { grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, grantsFor: async (): Promise => [], listApps: async () => [], ensureAuthConfig: async () => "not-needed" as const, diff --git a/server/tests/gate-grant-is-local.test.ts b/server/tests/gate-grant-is-local.test.ts index 54b205a6c..b5e8bb92e 100644 --- a/server/tests/gate-grant-is-local.test.ts +++ b/server/tests/gate-grant-is-local.test.ts @@ -37,6 +37,8 @@ const brokerStub: Omit = { // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, grantsFor: async () => [], listApps: async () => [], ensureAuthConfig: async () => "standing", diff --git a/server/tests/mcp-grant-removal.integration.test.ts b/server/tests/mcp-grant-removal.integration.test.ts index 175ace5f9..e359f980a 100644 --- a/server/tests/mcp-grant-removal.integration.test.ts +++ b/server/tests/mcp-grant-removal.integration.test.ts @@ -25,6 +25,8 @@ const botId = `agent_grant_removal_${suite}`; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index be918af8b..45ec322d1 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6614,6 +6614,8 @@ test("enabling an app writes a brokered row, and asks for its auth config first" const broker: ComposioBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a // broker to SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, @@ -6793,6 +6795,8 @@ function brokerSpy(answers: { broker: { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a // broker to SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/remote-bot-handoff.integration.test.ts b/server/tests/remote-bot-handoff.integration.test.ts index e1752a391..6ff4a5e14 100644 --- a/server/tests/remote-bot-handoff.integration.test.ts +++ b/server/tests/remote-bot-handoff.integration.test.ts @@ -37,6 +37,8 @@ const ALL = [REMOTE, BUILT_IN, OTHER_REMOTE]; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/routes-arcade-urls.test.ts b/server/tests/routes-arcade-urls.test.ts index 7213566ad..dd079bb7a 100644 --- a/server/tests/routes-arcade-urls.test.ts +++ b/server/tests/routes-arcade-urls.test.ts @@ -99,6 +99,8 @@ function deployment(provider: BrokerProvider) { const broker = { grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, authorizations: null, listApps: async () => [directoryApp(row.toolkit)], authorize: async (input: { toolkit: string }) => { diff --git a/server/tests/shared-brokered-routes.test.ts b/server/tests/shared-brokered-routes.test.ts index 34619e7f8..430e66a74 100644 --- a/server/tests/shared-brokered-routes.test.ts +++ b/server/tests/shared-brokered-routes.test.ts @@ -41,6 +41,8 @@ const rules: { const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 86be0fcac..3171f76ab 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -34,6 +34,8 @@ const revoked: string[] = []; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/shared-gated-mode.integration.test.ts b/server/tests/shared-gated-mode.integration.test.ts index 19a759a9a..620b30360 100644 --- a/server/tests/shared-gated-mode.integration.test.ts +++ b/server/tests/shared-gated-mode.integration.test.ts @@ -26,6 +26,8 @@ const VENDOR_ID = `openbot-deployment:acme-${suite}:R`; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/shared-wiring.integration.test.ts b/server/tests/shared-wiring.integration.test.ts index f87dfcaf4..614ba2fd7 100644 --- a/server/tests/shared-wiring.integration.test.ts +++ b/server/tests/shared-wiring.integration.test.ts @@ -27,6 +27,8 @@ const app = `wired-${suite}`; const broker: ConnectedAppBroker = { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts index 98c94ca88..c347b68b5 100644 --- a/server/tests/store-scheme-sites.test.ts +++ b/server/tests/store-scheme-sites.test.ts @@ -60,6 +60,8 @@ const unasked = (what: string) => async (): Promise => { function storeFor(provider: BrokerProvider) { const broker: ComposioBroker = { grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a broker to // SAY what it runs — see `ConnectedAppBroker.authorizations`. authorizations: null, From 19c9c52bcda263b5ddea8c4d2e678a954331e511 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:52:17 +0200 Subject: [PATCH 074/232] fix(arcade): the lifecycle trio filters on the auth provider, not the toolkit name connectionsFor compared a row's provider_id (the AUTH PROVIDER) to the toolkit name. They coincide only when an operator names the provider after the toolkit (Github, Asana, Attio: every toolkit in the fixtures). For Gmail, GoogleCalendar and GoogleDrive the filter matched nothing, so isConnected answered false for a connected account, revoke answered false ("nothing there") instead of deleting, and accountName answered null. The toolkit is now resolved through authProviderOf and rows are compared to that, still case-insensitively. An unresolved toolkit (null) answers as no rows and does NOT fall back to the toolkit name: isConnected false, revoke false with no delete spent, accountName null. The provider is resolved once per call. Amendment 16's "THE ONE PLACE the toolkit to provider_id mapping lives" sentence is retired; that is authProviderOf now. authProviderOf throws on a non-array listing, and that throw now propagates through isConnected, revoke and accountName, onto the Disconnect path. It is deliberately not swallowed. Mutation check: restoring the toolkit-name comparison turns the Gmail/google tests RED (3 fail); restoring the fix returns them green. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 21 ++- .../tests/arcade-adapter-connections.test.ts | 130 +++++++++++++++++- server/tests/arcade-adapter.test.ts | 11 ++ 3 files changed, 155 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 93a532c68..b47dda299 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -217,16 +217,29 @@ const FIELDS_REFUSAL = type ConnectionRow = Arcade.Admin.UserConnectionResponse; /** - * THE ONE PLACE the toolkit to `provider_id` mapping lives. It is read off each row's own - * `provider_id` in the `admin.userConnections` listing the adapter already performs (no extra - * vendor call), compared case-insensitively. A row for another app is never returned. + * The connections an account holds under a toolkit's AUTH PROVIDER. + * + * FILTERED ON THE PROVIDER, NOT THE TOOLKIT NAME. A row's `provider_id` is the auth-provider id, + * which equals the toolkit only when an operator happened to name it so (`Github`, `Asana`, + * `Attio`); one Google connection serves `Gmail` and `GoogleCalendar` both. So the toolkit is + * resolved through `authProviderOf`, which is where that mapping lives, and the rows are compared + * to THAT, case-insensitively (Amendment 16's client-side fold stands). The provider is resolved + * ONCE per call, never per row. + * + * AN UNRESOLVED TOOLKIT (`null`) ANSWERS AS NO ROWS and does NOT fall back to the toolkit name: a + * fallback would silently restore the defect under a different spelling, invisibly against every + * fixture in the tree. Callers then answer `isConnected` false, `revoke` false (no delete spent), + * `accountName` null. A malformed listing is different: `authProviderOf` THROWS, and that + * propagates, onto the Disconnect path included. */ async function connectionsFor( client: Arcade, vendorUserId: string, toolkit: string, ): Promise { - const wanted = toolkit.toLowerCase(); + const provider = await authProviderOf(client, toolkit); + if (provider === null) return []; + const wanted = provider.toLowerCase(); const rows: ConnectionRow[] = []; for await (const row of client.admin.userConnections.list({ user_id: vendorUserId, diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index 8a78befa1..bf8d75d02 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import Arcade from "@arcadeai/arcadejs"; import { useArcadeClient } from "../src/plugins/arcade"; import { createArcadeClient } from "../src/plugins/arcade-adapter"; @@ -11,9 +11,17 @@ const connectionsProto = (): Methods => new Arcade({ apiKey: "never-dialled" }).admin.userConnections, ) as Methods; +const toolsProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).tools, + ) as Methods; + const restores: (() => void)[] = []; -function stub(name: string, impl: (...args: unknown[]) => unknown) { - const on = connectionsProto(); +function stub( + name: string, + impl: (...args: unknown[]) => unknown, + on: Methods = connectionsProto(), +) { const was = on[name]; on[name] = impl; restores.push(() => { @@ -38,6 +46,38 @@ const row = (n: number, provider: string, info?: unknown) => ({ provider_user_info: info, }); +/** + * What the toolkit's listing says its auth provider is, keyed by the toolkit asked about. A toolkit + * absent from the map is answered by a listing that names no provider, i.e. UNRESOLVED. Counts the + * listing reads so a test can pin that a call resolves once. + */ +function providers(byToolkit: Record) { + const asked: unknown[] = []; + stub( + "list", + (query) => { + asked.push(query); + const toolkit = (query as { toolkit: string }).toolkit; + const provider = byToolkit[toolkit]; + return Promise.resolve({ + items: [ + { + qualified_name: `${toolkit}.A`, + requirements: { + authorization: { + id: "x", + ...(provider ? { provider_id: provider } : {}), + }, + }, + }, + ], + }); + }, + toolsProto(), + ); + return asked; +} + function listing(rows: unknown[]) { stub("list", () => (async function* () { @@ -46,7 +86,18 @@ function listing(rows: unknown[]) { ); } +/** The world every earlier case lived in: each provider is named after its own toolkit. */ +const NAMED_AFTER_TOOLKIT = { + Github: "github", + GitHub: "github", + Google: "google", +}; + describe("arcade adapter connection lifecycle", () => { + beforeEach(() => { + providers(NAMED_AFTER_TOOLKIT); + }); + test("isConnected is true on a non-empty list and false on an empty one", async () => { const rows: unknown[] = [row(1, "github")]; listing(rows); @@ -143,4 +194,77 @@ describe("arcade adapter connection lifecycle", () => { const { broker } = createArcadeClient("never-dialled"); expect(await broker.accountName({ account, toolkit: "Github" })).toBeNull(); }); + + test("a Gmail row under provider `google` is FOUND by all three members", async () => { + providers({ Gmail: "google" }); + listing([row(4, "google", { name: "Grace" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Gmail"; + expect(await broker.isConnected({ account, toolkit })).toBe(true); + expect(await broker.accountName({ account, toolkit })).toBe("Grace"); + expect(await broker.revoke({ account, toolkit })).toBe(true); + expect(deleted).toEqual(["ac_4"]); + }); + + test("the provider is compared case-insensitively", async () => { + providers({ Gmail: "Google" }); + listing([row(4, "google", { name: "Grace" })]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + }); + + test("a different provider on the same identity is still excluded", async () => { + providers({ Gmail: "google" }); + listing([row(1, "github", { name: "Ada" }), row(2, "google", {})]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBeNull(); + expect(await broker.revoke({ account, toolkit: "Gmail" })).toBe(true); + expect(deleted).toEqual(["ac_2"]); + }); + + test("an UNRESOLVED toolkit answers as no rows and never falls back to its name", async () => { + // `Gmail` is unresolved, and a row whose provider IS literally `gmail` exists: a fallback to + // the toolkit name would find it. + providers({}); + listing([row(1, "gmail", { name: "Grace" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Gmail"; + expect(await broker.isConnected({ account, toolkit })).toBe(false); + expect(await broker.accountName({ account, toolkit })).toBeNull(); + expect(await broker.revoke({ account, toolkit })).toBe(false); + expect(deleted).toEqual([]); + }); + + test("a malformed listing THROWS out of the lifecycle members rather than answering no rows", async () => { + stub("list", () => Promise.resolve({ items: "nope" }), toolsProto()); + listing([row(1, "gmail")]); + const { broker } = createArcadeClient("never-dialled"); + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + /action list/, + ); + }); + + test("each call resolves the provider once, however many rows there are", async () => { + const asked = providers({ Gmail: "google" }); + listing([row(1, "google"), row(2, "google"), row(3, "google")]); + stub("delete", () => Promise.resolve()); + const { broker } = createArcadeClient("never-dialled"); + await broker.revoke({ account, toolkit: "Gmail" }); + expect(asked).toHaveLength(1); + }); }); diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index abf132a26..0d52918f6 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -174,6 +174,17 @@ describe("createArcadeClient", () => { for (const r of rows) yield r; })(), ); + // The toolkit's own listing names the provider its connections are held under. + stub(toolsProto(), "list", () => + Promise.resolve({ + items: [ + { + qualified_name: "Github.A0", + requirements: { authorization: { id: "x", provider_id: "github" } }, + }, + ], + }), + ); const { broker } = createArcadeClient("never-dialled"); const ask = () => broker.accountName({ account, toolkit: "Github" }); rows.push({ From 9069d0ffb59e2d6e7228cab9506c18a40915f913 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:52:56 +0200 Subject: [PATCH 075/232] feat(arcade): finish the verifier, and give every exit a Location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 4 to 7 of the brokered per-action return leg: a bounded status wait (five polls over five seconds, never the SDK's unbounded waitForCompletion), the identity check, D28's freshness re-resolution with the retired identity's grant withdrawn, the one completion transaction, and the 303 onto the app's own page built from the attempt row's server_id. And the transport the route was missing. /oauth/callback in this same file already holds itself to "no request ends without a Location"; this is the file's second browser-navigation return leg and was the one not following it. Every refusal is now a 303 to the connected-accounts LIST carrying an opaque code on a new ?authorized= channel — never ?connected=, which is the Composio callback's and is untouched — and the sentences move to the app, which is the only place they are written down. The 403 keeps its JSON body, stated rather than inherited: the guard that refused it is the guard /api/me answers from, so that session cannot load the landing page either. Nothing from the status reply reaches a log line, a cause or an audit payload: the seam narrows it to three fields and the route reads a decision, a comparison and the scope list out of them. authProvider is asked of the broker rather than derived from the toolkit name. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/connected-accounts/index.tsx | 75 +- ...nected-accounts-authorized-notice.test.tsx | 73 ++ server/src/plugins/routes.ts | 322 +++++++-- server/tests/arcade-verify.test.ts | 652 ++++++++++++++++-- 4 files changed, 979 insertions(+), 143 deletions(-) create mode 100644 app/tests/connected-accounts-authorized-notice.test.tsx diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 91360247d..6a192acf3 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -56,10 +56,68 @@ export const Route = createFileRoute("/_authed/settings/connected-accounts/")({ * The key is omitted rather than set to undefined. Present-but-undefined makes `search` a required * prop on every Link to this route, which is a lot of ripple for a parameter only the callback sets. */ - validateSearch: (search: Record): { connected?: string } => - typeof search.connected === "string" ? { connected: search.connected } : {}, + validateSearch: ( + search: Record, + ): { connected?: string; authorized?: string } => ({ + ...(typeof search.connected === "string" + ? { connected: search.connected } + : {}), + /* + * A CHANNEL OF ITS OWN, BESIDE `?connected=` AND NEVER FOLDED INTO IT. That one is the Composio + * OAuth callback's and carries a server key or `failed`; this one is the brokered per-action + * verifier's and carries a reason code. Two senders answering different questions on one + * parameter is how a notice comes to be drawn for the wrong thing. + */ + ...(typeof search.authorized === "string" + ? { authorized: search.authorized } + : {}), + }), }); +/** + * WHAT THE VERIFIER REFUSED, IN WORDS — AND THE WORDS LIVE HERE AND NOWHERE ELSE. + * + * THE SENTENCE NEVER TRAVELS IN THE URL. A message rendered from a query string is a message + * anybody can author on this deployment's own origin: a link could be sent around carrying whatever + * sentence its author liked, shown in this page's own voice under this deployment's own address. + * What travels is an opaque code the server chose from a closed set, and this map is the only place + * it becomes something a person reads — which is also what keeps one sentence from being maintained + * in two places that drift apart. + * + * AN UNRECOGNISED CODE DRAWS NOTHING, deliberately. A code this build does not know is either + * somebody probing the parameter or a deployment mid-upgrade, and a generic "something went wrong" + * for both would be a sentence that tells a person nothing and an invitation to keep trying. + */ +export const AUTHORIZATION_NOTICES: Record = { + "no-broker": + "This deployment is not set up to connect apps, so nothing was recorded. Ask an administrator to set it up.", + "not-supported": + "This deployment's apps are connected once for the whole app, with the Connect button on the app's page; they are not authorized one action at a time.", + "missing-handle": + "That link is missing the part that says which authorization it finishes, so nothing was done. Start the authorization again from the app's page.", + "no-attempt": + "There is no authorization in progress for you to finish — it may have expired, or it may have been started by somebody else or in another session. Start it again from the app's page.", + "vendor-unavailable": + "The app's vendor did not answer when this deployment tried to finish that authorization, so nothing was recorded. Try the link again in a moment.", + "not-your-authorization": + "That link does not finish the authorization you have in progress, so nothing was recorded and it is still waiting. Finish it from the tab it was started in, or start it again from the app's page.", + "not-finished": + "That authorization has not finished yet, so nothing was recorded. Finish it at the app's vendor, then open the link again — it is still waiting.", + "authorization-failed": + "The app's vendor did not complete that authorization, so nothing was recorded. Start it again from the app's page.", + "context-changed": + "That app's account changed while the authorization was open, so nothing was recorded and anything granted for the old account was withdrawn. Start it again from the app's page.", + "not-recorded": + "This deployment could not record that authorization, so none of it was saved. Start it again from the app's page, and tell an administrator if it persists.", +}; + +/** The sentence for a code the verifier sent back, or null for one this build does not know. */ +export function authorizationNoticeFor( + authorized: string | undefined, +): string | null { + return authorized ? (AUTHORIZATION_NOTICES[authorized] ?? null) : null; +} + /** The same marks the admin connector list uses: these are the same vendors seen from your side. */ const MARKS: Record> = { "google-drive": IconBrandGoogleDrive, @@ -131,7 +189,8 @@ export function connectedAccountSections( } function RouteComponent() { - const { connected: outcome } = Route.useSearch(); + const { connected: outcome, authorized } = Route.useSearch(); + const authorizationNotice = authorizationNoticeFor(authorized); const [search, setSearch] = React.useState(""); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); @@ -240,6 +299,16 @@ function RouteComponent() { That account could not be connected. Nothing was saved — try again.

) : null} + {/* + * The brokered per-action verifier's own channel. It lands here rather than on an app's page + * because three of its refusals have no app to name, and a notice that sometimes appears one + * level in and sometimes here would be a notice people learn to look for in two places. + */} + {authorizationNotice ? ( +

+ {authorizationNotice} +

+ ) : null} {/* * BOTH READS DECIDE THIS, AND ONLY ONE OF THEM USED TO. The waits were already paired here; * the errors were not — this branch tested `plugins.error` alone, so a `/api/plugins` that diff --git a/app/tests/connected-accounts-authorized-notice.test.tsx b/app/tests/connected-accounts-authorized-notice.test.tsx new file mode 100644 index 000000000..734d4f149 --- /dev/null +++ b/app/tests/connected-accounts-authorized-notice.test.tsx @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { + AUTHORIZATION_NOTICES, + authorizationNoticeFor, +} from "@/routes/_authed/settings/connected-accounts/index"; + +/** + * The notice channel the brokered per-action verifier reports back on, decided without drawing + * anything. + * + * THE SENTENCE LIVES HERE AND THE URL CARRIES ONLY A CODE, which is the whole point of the channel. + * A message rendered from a query string is a message anybody can author on this deployment's own + * origin: a link could be passed around carrying whatever sentence its author liked, shown in this + * page's voice under this deployment's address. So the server chooses from a closed set of opaque + * codes and this map is the only place one becomes words — which is also what stops a sentence + * being maintained in two places that drift. + * + * A `.tsx` file because it imports a route module, which is JSX; the precedent is + * `connected-accounts-list.test.tsx`, which exports its own rule as a function for this reason. + */ + +/** + * THE SAME CLOSED SET THE SERVER HOLDS, asserted as a set here too. The server's own suite asserts + * `AUTHORIZATION_REFUSALS` against this same list, so a code added on one side without the other + * turns one of the two red: a new code with no sentence would otherwise draw nothing at all. + */ +test("every reason the verifier can send has a sentence here, and no others", () => { + expect(Object.keys(AUTHORIZATION_NOTICES)).toEqual([ + "no-broker", + "not-supported", + "missing-handle", + "no-attempt", + "vendor-unavailable", + "not-your-authorization", + "not-finished", + "authorization-failed", + "context-changed", + "not-recorded", + ]); +}); + +test("each code reads as its own sentence, not as one generic apology", () => { + const sentences = Object.values(AUTHORIZATION_NOTICES); + expect(new Set(sentences).size).toBe(sentences.length); + for (const sentence of sentences) { + // Long enough to say what happened and what to do about it, rather than an apology. + expect(sentence.length).toBeGreaterThan(60); + } +}); + +test("a code is turned into the words for it", () => { + expect(authorizationNoticeFor("no-attempt")).toBe( + AUTHORIZATION_NOTICES["no-attempt"], + ); +}); + +/** + * AND A CODE THIS BUILD DOES NOT KNOW DRAWS NOTHING, deliberately. Such a code is either somebody + * probing the parameter or a deployment mid-upgrade; a generic "something went wrong" for both + * would tell a person nothing and invite them to keep trying. + */ +test("an unrecognised or absent code draws nothing", () => { + expect(authorizationNoticeFor("something-somebody-made-up")).toBeNull(); + expect(authorizationNoticeFor("")).toBeNull(); + expect(authorizationNoticeFor(undefined)).toBeNull(); +}); + +/** The sentence never travels in the URL, so nothing in the map may be read out of one. */ +test("the notices are not reachable from the search parameter's own value", () => { + expect( + authorizationNoticeFor("That account could not be connected."), + ).toBeNull(); +}); diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index c0954257e..7bcd4c1d0 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -9,6 +9,7 @@ import type { AccountModeSwitch } from "./account-mode"; import { type BrokerProvider, toolkitOfFor } from "./access"; import { normalizeArcadeError } from "./arcade-adapter"; import { + type AuthorizationFlow, type BrokerApp, type BrokerField, BrokerUnconfiguredError, @@ -139,6 +140,84 @@ function signInUrlFor(appUrl: string | undefined, flowId: string): string { return flowId ? `${base}?flow_id=${encodeURIComponent(flowId)}` : base; } +/** + * WHY THE VERIFIER REFUSED, AS AN OPAQUE CODE — and the CLOSED SET of them. + * + * THE CODE TRAVELS AND THE SENTENCE DOES NOT. Each of these is rendered by the connected-accounts + * list, which holds the words; a message rendered from a query string is a message anybody can + * author on this deployment's own origin, and the code is also what keeps the copy in ONE place + * rather than in two that drift. The parameter is `?authorized=`, a channel of its own: `?connected=` + * belongs to the Composio OAuth callback and is neither reused nor widened here. + * + * ASSERTED AS A SET so a seventh cannot be added without a test going red, and so the app's own map + * can be held to exactly the same list. + */ +export const AUTHORIZATION_REFUSALS = [ + // This deployment reaches no broker at all, so there is no return leg to verify. + "no-broker", + // The broker it reaches declares no correlated authorization flow. + "not-supported", + // The link arrived without the part that says which authorization it finishes. + "missing-handle", + // Nothing of this person's is waiting: never started, expired, or another session's. + "no-attempt", + // The vendor was asked and did not answer. + "vendor-unavailable", + // The link, or the authorization behind it, is not the one this person is holding. + "not-your-authorization", + // The consent has not finished at the vendor yet. The attempt is LEFT STANDING for a retry. + "not-finished", + // The vendor says the authorization did not complete. + "authorization-failed", + // The app's account context moved on while the consent was open (D28). + "context-changed", + // This deployment could not write what it had, so it wrote none of it. + "not-recorded", +] as const; + +export type AuthorizationRefusal = (typeof AUTHORIZATION_REFUSALS)[number]; + +/** Whether two account references name the same account, holder included. */ +function sameAccount(left: AccountRef, right: AccountRef): boolean { + if (left.vendorUserId !== right.vendorUserId) return false; + if (left.holder === "person") { + return right.holder === "person" && left.userId === right.userId; + } + return right.holder === "deployment"; +} + +/** At most five polls over about five seconds. NEVER the SDK's unbounded `waitForCompletion`. */ +const STATUS_POLLS = 5; +const STATUS_PAUSE_MS = 1000; + +/** + * Where one authorization has got to, waited for with a BOUND. + * + * `pending` is the only state worth asking again about, and asking five times over five seconds is + * the whole of the patience this has. A timeout is neither a success nor a failure, so what comes + * back is the last answer read and the caller decides; nothing here writes, retries forever or + * blocks a browser on a vendor that has stopped answering. + * + * WHAT COMES BACK IS THE SEAM'S THREE FIELDS AND NOTHING MORE (D31). The vendor's own status reply + * carries a live provider access token and the person's full profile; the adapter drops them, and + * this function must never widen what it hands on, log it, or attach it as a `cause`. + */ +async function settledStatus( + flow: AuthorizationFlow, + id: string, +): Promise>> { + let answer = await flow.status(id); + for ( + let poll = 1; + poll < STATUS_POLLS && answer.status === "pending"; + poll += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, STATUS_PAUSE_MS)); + answer = await flow.status(id); + } + return answer; +} + /** * The Plugins surface: what this deployment has added, and which Bots may use it. * @@ -2473,12 +2552,37 @@ export function createPluginRoutes( * signed-in person. Signing in as somebody else finds no attempt and refuses, which is correct and * occasionally annoying. * - * THIS HALF REFUSES AND NOTHING MORE. The bounded `auth.status` wait, the identity check, the - * freshness re-resolution, the completion transaction and the 303 landing are a separate step and - * are deliberately absent: nothing below polls an authorization, completes an attempt or writes a - * connection row. Every exit from this handler leaves the database exactly as it found it. + * CRITERION: NO REQUEST TO THIS ENDPOINT ENDS WITHOUT A `Location`, WITH ONE STATED EXCEPTION. + * That is the criterion `/oauth/callback` below already holds itself to in as many words, and this + * is the file's second browser-navigation return leg — so a refusal here ends the same way that + * one does: back at the connected-accounts LIST, with a word about what happened and nothing + * written. The reason travels as an opaque code on `?authorized=` + * ({@link AUTHORIZATION_REFUSALS}); the sentence belongs to the app, which is the only place it is + * written down. THE LIST AND NOT AN APP PAGE, uniformly: three of the refusals have no attempt row + * to build an app address from, and {@link connectedAccountsUrlFor} already argues that picking + * one would be a guess about what somebody had been doing. + * + * THE EXCEPTION IS THE 403, AND IT IS STATED RATHER THAN INHERITED. The guard that refuses a + * session with no role is the same guard `/api/me` answers from, so such a session cannot reliably + * load the landing page either — it is sent to `/sign` or into an error boundary, which is the + * dead end that branch exists to avoid. It keeps its JSON body. */ routes.get("/arcade/verify", async (context) => { + /** + * Where a refusal lands, which is the LIST screen with the reason beside it. + * + * Built from {@link connectedAccountsUrlFor}'s failure address with its query dropped, so the + * one place that knows how to address the app's origin stays the one place that knows — and so + * that `?connected=failed`, which belongs to the Composio callback, is NOT carried along beside + * `?authorized=` and drawn as a second notice about something that did not happen. + */ + const refusedWith = (reason: AuthorizationRefusal) => { + const [screen] = connectedAccountsUrlFor(connect?.appUrl, { + failed: true, + }).split("?"); + return context.redirect(`${screen}?authorized=${reason}`, 303); + }; + /* * Read, not yet judged. The redirect below has to carry it, and that redirect happens before * the value is validated — reading a query string calls nobody and writes nothing. @@ -2515,22 +2619,9 @@ export function createPluginRoutes( * different things and the exemption failed OPEN. A deployment whose broker runs no correlated * authorization flow has no return leg for this route to verify. */ - if (!composio) { - return context.json( - { error: new BrokerUnconfiguredError().message }, - 503, - ); - } + if (!composio) return refusedWith("no-broker"); const flow = composio.broker.authorizations; - if (!flow) { - return context.json( - { - error: - "This deployment's apps are connected once for the whole app, with the Connect button on the app's page; they are not authorized one action at a time.", - }, - 503, - ); - } + if (!flow) return refusedWith("not-supported"); /* * A HANDLE THIS DEPLOYMENT CANNOT USE IS REFUSED WITHOUT ASKING THE VENDOR ANYTHING. Empty, @@ -2538,15 +2629,7 @@ export function createPluginRoutes( * return leg carries — and spending a vendor call on it would be an unauthenticated-shaped * request reaching another company's API. */ - if (!flowId) { - return context.json( - { - error: - "That link is missing the part that says which authorization it finishes, so nothing was done. Start the authorization again from the app's page.", - }, - 400, - ); - } + if (!flowId) return refusedWith("missing-handle"); /* * THE ATTEMPT COMES FROM THE SIGNED-IN ACTOR AND NEVER FROM THE HANDLE. The vendor's return-leg @@ -2562,15 +2645,8 @@ export function createPluginRoutes( */ const actorId = context.var.actor.id; const attempt = await store.authorizationAttemptFor(actorId); - if (!attempt || attempt.authId === null) { - return context.json( - { - error: - "There is no authorization in progress for you to finish — it may have expired, or it may have been started by somebody else or in another session. Start it again from the app's page.", - }, - 400, - ); - } + if (!attempt || attempt.authId === null) return refusedWith("no-attempt"); + const authId = attempt.authId; /* * THE BINDING. `handle` IS AN OPAQUE STRING THE SEAM DOES NOT INTERPRET — the vendor's word for @@ -2598,13 +2674,7 @@ export function createPluginRoutes( * the error. A reply on this leg can carry an authorization url, which is a bearer capability, * and an outage is not worth a person's consent being quotable out of an error body. */ - return context.json( - { - error: - "The app's vendor did not answer when this deployment tried to finish that authorization, so nothing was recorded. Try the link again in a moment.", - }, - 502, - ); + return refusedWith("vendor-unavailable"); } /* @@ -2622,34 +2692,150 @@ export function createPluginRoutes( * NOTHING IS WRITTEN AND NOTHING IS CLOSED. A late callback must never consume or ALTER the * replacement it arrived behind, so the row is not deleted, not completed and not marked. */ - if ( - "mismatch" in confirmed || - confirmed.authorizationId !== attempt.authId - ) { - return context.json( - { - error: - "That link does not finish the authorization you have in progress, so nothing was recorded and it is still waiting. Finish it from the tab it was started in, or start it again from the app's page.", - }, - 400, - ); + if ("mismatch" in confirmed || confirmed.authorizationId !== authId) { + return refusedWith("not-your-authorization"); } /* - * EVERYTHING PAST THE BINDING BELONGS TO THE OTHER HALF OF THIS VERIFIER: the bounded wait on - * the authorization's status, the identity check against it, the freshness re-resolution, the - * one transaction that writes the connection row and its audit entry, and the 303 that lands the - * person back on the page they left from. Until that half lands, a return leg that binds - * correctly is answered as a capability this deployment does not yet have — which keeps the - * promise this half makes: NOTHING IS WRITTEN HERE, on any path. + * THE BOUNDED WAIT. `pending` is polled a few times over a few seconds and never with the SDK's + * unbounded `waitForCompletion`: a browser is held open on this request, and a vendor that has + * stopped answering must not hold it open forever. See {@link settledStatus}. + * + * NOTHING FROM THAT ANSWER TRAVELS ANYWHERE (D31). The vendor's own status reply carries a LIVE + * PROVIDER ACCESS TOKEN and the person's full profile; the seam narrows it to three fields + * inside the adapter, and the three are read here into a decision, a comparison and the scope + * list the completion writes. Not one of them is logged, attached as a `cause` or put in an + * error body, and the status object itself reaches none of those either. A status response + * LOOKS LIKE METADATA, which is how the throwaway harness that proved this flow came to write + * the token to a file on its first run. */ - return context.json( - { - error: - "This deployment cannot finish that authorization yet, so nothing was recorded. The authorization is still waiting, and nothing at the app's vendor was changed.", - }, - 503, - ); + let settled: Awaited>; + try { + settled = await settledStatus(flow, authId); + } catch { + return refusedWith("vendor-unavailable"); + } + + /* + * EVERYTHING FROM HERE IS IN ONE `try`, FOR THE REASON `/oauth/callback` BELOW GIVES FOR ITS + * OWN: the criterion is that no request to this endpoint ends without a `Location`, and a store + * call that throws would otherwise put somebody who has just consented at another company on a + * bodyless 500 on an origin that serves no pages. The completion transaction is all-or-nothing, + * so a throw out of it has written neither the connection row nor its audit entry. + */ + try { + /* + * WHAT EACH OUTCOME DOES TO THE ATTEMPT ROW, AND THE TIMEOUT IS THE ONE THAT LEAVES IT ALONE. + * `failed` and an identity that is not the attempt's are verdicts, so the row is consumed and + * marked. STILL `pending` AT THE BOUND IS NEITHER A SUCCESS NOR A FAILURE: the consent may be + * finished a second later, and a row closed here would mean a person who came back to the + * link found nothing to finish. It stands until its TTL. + */ + if (settled.status === "failed") { + await store.failAuthorizationAttempt({ actorId, authId }); + return refusedWith("authorization-failed"); + } + if (settled.status !== "completed") return refusedWith("not-finished"); + if (settled.userId !== attempt.vendorUserId) { + await store.failAuthorizationAttempt({ actorId, authId }); + return refusedWith("not-your-authorization"); + } + + /* + * FRESHNESS (D28): A CONSENT WHOSE CONTEXT HAS MOVED ON IS NOT COMPLETED INTO THE PRESENT. + * + * The app's CURRENT account, its mode, the active provider and this actor's current authority + * are all re-resolved through the same resolver the mint used, and all of them have to still + * be the attempt's. The window is one consent's TTL and it needs an administrator to switch an + * app's mode twice inside it — which is rare, and silent: writing it would create a live grant + * bound to a retired identity that nothing will ever call with, and on an app somebody + * disconnected in between it would quietly resurrect the account. + * + * AND THE GRANT MAY ALREADY BE LIVE AT THE VENDOR, because the person completed it there + * before this deployment looked. So the RETIRED identity's connection is withdrawn — keyed on + * that identity and on this app, which CANNOT TOUCH THE CURRENT ACCOUNT: a connection is keyed + * `(user_id, provider_id)` and the retired identity is not the current one. Where that + * withdrawal fails the attempt is kept as `unreconciled` rather than closed, because a grant + * we know about and could not end is worth a row somebody can act on. + */ + const retired: AccountRef = + attempt.holder === "person" && attempt.userId + ? { + holder: "person", + userId: attempt.userId, + vendorUserId: attempt.vendorUserId, + } + : { holder: "deployment", vendorUserId: attempt.vendorUserId }; + const fresh = await brokeredAccountFor(context, attempt.serverId); + if (fresh.refusal || !sameAccount(fresh.account, retired)) { + const row = await store.serverAddress(attempt.serverId); + const retiredApp = row ? appOfUrl(row.url) : null; + let withdrawn = false; + if (retiredApp) { + try { + await composio.broker.revoke({ + account: retired, + toolkit: retiredApp, + }); + withdrawn = true; + } catch { + // Nothing from the vendor's answer travels with this, here as everywhere on this leg. + withdrawn = false; + } + } + if (withdrawn) + await store.failAuthorizationAttempt({ actorId, authId }); + else await store.markAuthorizationUnreconciled({ actorId, authId }); + return refusedWith("context-changed"); + } + + /* + * ONE TRANSACTION, AND A REPLAY IS A NO-OP RATHER THAN AN ERROR. The attempt row is the + * serialization point: zero rows updated means somebody else completed this attempt first, and + * the loser is answered exactly as the winner is and writes nothing. See + * {@link PluginStore.completeAuthorizationAttempt}. + * + * `authProvider` IS ASKED OF THE BROKER AND NEVER DERIVED FROM THE TOOLKIT NAME. The two + * coincide only where an operator happened to name their provider configuration after the + * app, which {@link ConnectedAppBroker.authProviderOf} sets out at length — so a derivation + * would pass every test in this tree and be wrong about the deployment it shipped to. A + * vendor that does not say is recorded as not saying, and a vendor that cannot be reached for + * it costs nobody their completed consent: this is an audit label, and the grant is already + * live. It is never read back out of a status reply, which D31's narrowing makes + * structurally impossible anyway. + */ + const authProvider = await composio.broker + .authProviderOf(fresh.toolkit) + .catch(() => null); + await store.completeAuthorizationAttempt({ + actorId, + authId, + toolkit: fresh.toolkit, + account: fresh.account, + action: attempt.action, + scopes: settled.scopes, + authProvider: authProvider ?? "", + }); + + /* + * AND THE LANDING COMES FROM THE ATTEMPT ROW (D18), NEVER FROM `next_uri`. The vendor refuses + * every `next_uri` at mint, and what `confirm_user` answers is the vendor's OWN success page + * rather than ours. The row holds the app and the action this consent was for, and it cannot + * be missing here — this handler refused long before it needed an address — so there is no + * fallback to specify. An account the deployment holds goes back to the admin screen it was + * started from, as the mint's own return address does. + */ + return context.redirect( + connectedAccountsUrlFor( + connect?.appUrl, + { serverId: attempt.serverId }, + fresh.account.holder === "deployment" ? "admin" : "settings", + ), + 303, + ); + } catch { + return refusedWith("not-recorded"); + } }); /** diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 0c4e2dd19..c44f12506 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -1,13 +1,19 @@ // server/tests/arcade-verify.test.ts /** - * The verifier's REFUSAL half: a browser coming back from a brokered per-action consent that this - * deployment will not complete. + * The verifier: a browser coming back from a brokered per-action consent, refused or completed. * * ALMOST EVERY ASSERTION HERE IS "NOTHING WAS WRITTEN", which is the shape where a broken * implementation looks exactly like a passing one. So no case infers that from a status code: - * each one QUERIES `brokered_connections` for a row belonging to the actor, and `audit_events` for - * the `mcp.account_connected` entry the completion transaction files, and requires both to be - * empty. The actor ids are suite-scoped, so a count of zero is a fact about this run. + * each one QUERIES `brokered_connections` for a row belonging to this suite's app, and + * `audit_events` for the `mcp.account_connected` entry the completion transaction files, and + * requires both to be empty. The app name is suite-scoped, so a count of zero is a fact about this + * run. + * + * NOR DOES ANY CASE INFER A REFUSAL FROM "IT REDIRECTED". Every exit from this route answers a + * `Location` — the criterion `/oauth/callback` already holds itself to — so a test that checked the + * 303 alone would pass for every refusal including the wrong one. Each refusal names the reason code + * its `?authorized=` parameter has to carry, one for one, and the codes are asserted once AS A SET + * so a new one cannot appear without this file going red. * * AND IT DRIVES THROUGH `createApp`, NOT THROUGH `createPluginRoutes`. A route that is built but * never mounted answers 404 in production while its own factory test passes — the failure every @@ -16,7 +22,7 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { getCookies } from "better-auth/cookies"; -import { and, eq } from "drizzle-orm"; +import { and, eq, like } from "drizzle-orm"; import { createApp } from "../src/app"; import { createAuditStore } from "../src/audit"; import { createAuth } from "../src/auth/index"; @@ -29,25 +35,67 @@ import { pendingAuthorizations, } from "../src/db/schema"; import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { AUTHORIZATION_REFUSALS } from "../src/plugins/routes"; +import type { AccountRef } from "../src/plugins/shared-accounts"; import { createPluginStore } from "../src/plugins/store"; -import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testDatabaseUrl } from "./support/database"; import { testEnvironment } from "./support/environment"; -const database = createDatabase(testDatabaseUrl(), TEST_POOL); +/* + * SIX CONNECTIONS RATHER THAN THE SHARED `TEST_POOL` OF TWO, because the race below drives two + * completion TRANSACTIONS at once through two whole apps, and the second blocks on the first's row + * lock while holding its own connection. `support/database.ts` says a file wanting a different pool + * asks for one itself. + */ +const database = createDatabase(testDatabaseUrl(), { max: 6 }); const suite = randomUUID().slice(0, 8); const APP = `arcade-verify-${suite}`; +/** + * ONE APP PER TEST, because `audit_events` is APPEND-ONLY and cannot be cleaned between them. A + * fixed name would let a completion's event be counted by the next case's "nothing was written", + * which is the exact assertion this suite is built on. The app is keyed on its toolkit, so a fresh + * toolkit per test is a fresh everything. + */ +let TOOLKIT = ""; +let apps = 0; const ACTOR = `verifier-${suite}`; const STRANGER = `stranger-${suite}`; +const SHARED_IDENTITY = `shared-${suite}`; const ACTION = "Gmail.ListEmails"; const MINTED = "ar_minted_for_this_attempt"; const APP_URL = "https://app.example"; +/** A stand-in for the live `gho_…` the vendor's own status reply carries beside the three fields. */ +const TOKEN = `gho_live_access_token_${suite}`; /** Every call this suite lets reach the seam. A refusal that spends one is a refusal too late. */ let confirmCalls: { handle: string; vendorUserId: string }[] = []; +let statusCalls: string[] = []; +let revokeCalls: { account: AccountRef; toolkit: string }[] = []; /** What the next `confirmReturn` answers, or throws. Set per test. */ let confirmAnswer: () => Promise< { authorizationId: string } | { mismatch: true } > = async () => ({ authorizationId: MINTED }); +/** + * What the next status read answers. DELIBERATELY WIDER THAN THE SEAM in one case below: the + * vendor's real reply carries a live access token and a full profile, and the point of that case is + * that nothing this route touches can carry either anywhere. + */ +let statusAnswer: () => Promise<{ + status: string; + userId: string | null; + scopes: string[]; +}> = async () => ({ + status: "completed", + userId: ACTOR, + scopes: ["gmail.read"], +}); +let revokeAnswer: () => Promise = async () => true; +/** + * What auth provider the app's connections are held under. NEVER THE TOOLKIT NAME: the two coincide + * only where an operator named their provider configuration after the app, so a route deriving one + * from the other would pass here and be wrong where it shipped. + */ +let authProviderAnswer: () => Promise = async () => "google"; /** * A broker that DECLARES the correlated authorization flow, which is the only thing this route's @@ -56,11 +104,11 @@ let confirmAnswer: () => Promise< */ const broker: ConnectedAppBroker = { grantIsLocal: false, - // Nothing here asks what auth provider a connection is held under. - authProviderOf: async () => null, + authProviderOf: async () => authProviderAnswer(), authorizations: { - status: async () => { - throw new Error("the refusal half polls no authorization"); + status: async (id) => { + statusCalls.push(id); + return statusAnswer(); }, confirmReturn: async (query) => { confirmCalls.push(query); @@ -76,7 +124,10 @@ const broker: ConnectedAppBroker = { authorizationId: MINTED, }), isConnected: async () => true, - revoke: async () => true, + revoke: async (request) => { + revokeCalls.push(request); + return revokeAnswer(); + }, connectionFields: async () => [], connectWithFields: async () => ({ accountId: "ca_1" }), revokeAccount: async () => {}, @@ -95,12 +146,15 @@ function build( options: { roles?: string[]; brokerBag?: { broker: ConnectedAppBroker; provider: string } | undefined; + /** A handle whose behaviour one case alters; everything else runs on the suite's own. */ + database?: typeof database; } = {}, ) { - const auditStore = createAuditStore(database); + const handle = options.database ?? database; + const auditStore = createAuditStore(handle); const pluginStore = createPluginStore({ brokerProvider: "arcade", - database, + database: handle, auditStore, credentials: {} as never, encryptionKey: "x".repeat(44), @@ -139,33 +193,60 @@ function verify( ); } +/** Where a refusal lands: the LIST, with the reason as an opaque code and no sentence. */ +const refusedTo = (reason: string) => + `${APP_URL}/settings/connected-accounts?authorized=${reason}`; + +/** Where a completion lands: the app's own page, built from the attempt row's `server_id`. */ +const LANDING = `${APP_URL}/settings/connected-accounts/${APP}`; + +/** + * A refusal, named by its code and not merely by the fact that something redirected. + * + * The transport is the same for all of them, so a check that stopped at the 303 would pass for + * every one including whichever one is wrong. The code is the whole of the discrimination the + * status codes used to carry — and `?connected=`, which belongs to the Composio callback, is never + * along for the ride. + */ +async function expectRefusal(response: Response, reason: string) { + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe(refusedTo(reason)); + expect(response.headers.get("location")).not.toContain("connected="); +} + /** What the completion transaction would have left behind. Queried, never inferred from a status. */ -async function written(actorId: string) { +async function written() { const connections = await database .select() .from(brokeredConnections) - .where(eq(brokeredConnections.userId, actorId)); + .where(eq(brokeredConnections.app, TOOLKIT)); const events = await database .select() .from(auditEvents) .where( and( - eq(auditEvents.actorUserId, actorId), + eq(auditEvents.targetId, TOOLKIT), eq(auditEvents.eventType, "mcp.account_connected"), ), ); - return { connections: connections.length, successEvents: events.length }; + return { connections, events }; } async function expectNothingWritten() { - expect(await written(ACTOR)).toEqual({ - connections: 0, - successEvents: 0, - }); - expect(await written(STRANGER)).toEqual({ - connections: 0, - successEvents: 0, - }); + const { connections, events } = await written(); + expect({ + connections: connections.length, + successEvents: events.length, + }).toEqual({ connections: 0, successEvents: 0 }); +} + +/** The attempt row itself, which is what every "nothing was written" case is really about. */ +async function attemptRow(actorId = ACTOR) { + const [row] = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, actorId)); + return row ?? null; } /** @@ -195,7 +276,18 @@ async function giveAttempt(options: { beforeEach(async () => { confirmCalls = []; + statusCalls = []; + revokeCalls = []; confirmAnswer = async () => ({ authorizationId: MINTED }); + statusAnswer = async () => ({ + status: "completed", + userId: ACTOR, + scopes: ["gmail.read"], + }); + revokeAnswer = async () => true; + authProviderAnswer = async () => "google"; + apps += 1; + TOOLKIT = `Gmail-${suite}-${apps}`; await database .delete(pendingAuthorizations) .where(eq(pendingAuthorizations.actorId, ACTOR)); @@ -204,7 +296,7 @@ beforeEach(async () => { id: APP, title: "Gmail", vendor: "Arcade", - url: `arcade://Gmail-${suite}`, + url: `arcade://${TOOLKIT}`, provenance: "arcade", authScheme: "OAUTH2", accountMode: "personal", @@ -215,53 +307,75 @@ afterAll(async () => { await database .delete(pendingAuthorizations) .where(eq(pendingAuthorizations.actorId, ACTOR)); + await database + .delete(brokeredConnections) + .where(like(brokeredConnections.app, `Gmail-${suite}-%`)); await database.delete(mcpServers).where(eq(mcpServers.id, APP)); }); +/** + * THE CLOSED SET, ASSERTED ONCE AND AS A SET. + * + * Each refusal below names one of these, so the list and the cases hold each other up: a code added + * to the route without a case here fails this, and a case naming a code the route cannot send fails + * its own `Location`. + */ +test("the reasons the verifier can refuse with are a closed set", () => { + expect([...AUTHORIZATION_REFUSALS]).toEqual([ + "no-broker", + "not-supported", + "missing-handle", + "no-attempt", + "vendor-unavailable", + "not-your-authorization", + "not-finished", + "authorization-failed", + "context-changed", + "not-recorded", + ]); +}); + describe("a return leg this deployment will not complete", () => { test("an empty flow_id is refused without the vendor being asked", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); - const response = await verify(ACTOR, "?flow_id="); - expect(response.status).toBe(400); + await expectRefusal(await verify(ACTOR, "?flow_id="), "missing-handle"); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); test("a missing flow_id is refused without the vendor being asked", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); - const response = await verify(ACTOR, ""); - expect(response.status).toBe(400); + await expectRefusal(await verify(ACTOR, ""), "missing-handle"); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); test("a whitespace-only flow_id is refused without the vendor being asked", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); - const response = await verify(ACTOR, "?flow_id=%20%20%09"); - expect(response.status).toBe(400); + await expectRefusal( + await verify(ACTOR, "?flow_id=%20%20%09"), + "missing-handle", + ); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); test("no attempt at all is refused", async () => { - const response = await verify(ACTOR, "?flow_id=flow-abc"); - expect(response.status).toBe(400); + await expectRefusal(await verify(ACTOR, "?flow_id=flow-abc"), "no-attempt"); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); test("an attempt past the fifteen-minute TTL is refused", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 20 }); - const response = await verify(ACTOR, "?flow_id=flow-abc"); - expect(response.status).toBe(400); + await expectRefusal(await verify(ACTOR, "?flow_id=flow-abc"), "no-attempt"); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); test("an attempt whose mint never answered an id is refused", async () => { await giveAttempt({ authId: null, ageMinutes: 1 }); - const response = await verify(ACTOR, "?flow_id=flow-abc"); - expect(response.status).toBe(400); + await expectRefusal(await verify(ACTOR, "?flow_id=flow-abc"), "no-attempt"); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); @@ -270,20 +384,20 @@ describe("a return leg this deployment will not complete", () => { * THE LATE CALLBACK. The vendor confirms, honestly, an authorization that is not the one this * deployment is holding — the read attempt that expired arriving behind the send attempt that * replaced it. The replacement must be left EXACTLY as it was, so the row itself is read back - * rather than the status code being trusted to imply it. + * rather than the answer being trusted to imply it. */ test("a confirmation naming a different attempt refuses and leaves the row untouched", async () => { const createdAt = await giveAttempt({ authId: MINTED, ageMinutes: 1 }); confirmAnswer = async () => ({ authorizationId: "ar_some_other_attempt" }); - const response = await verify(ACTOR, "?flow_id=flow-abc"); - expect(response.status).toBe(400); + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "not-your-authorization", + ); expect(confirmCalls).toEqual([{ handle: "flow-abc", vendorUserId: ACTOR }]); + expect(statusCalls).toEqual([]); - const [row] = await database - .select() - .from(pendingAuthorizations) - .where(eq(pendingAuthorizations.actorId, ACTOR)); + const row = await attemptRow(); expect(row?.completedAt).toBeNull(); expect(row?.outcome).toBeNull(); expect(row?.authId).toBe(MINTED); @@ -301,13 +415,12 @@ describe("a return leg this deployment will not complete", () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); confirmAnswer = async () => ({ mismatch: true }); - const response = await verify(ACTOR, "?flow_id=flow-abc"); - expect(response.status).toBe(400); + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "not-your-authorization", + ); - const [row] = await database - .select() - .from(pendingAuthorizations) - .where(eq(pendingAuthorizations.actorId, ACTOR)); + const row = await attemptRow(); expect(row?.completedAt).toBeNull(); expect(row?.outcome).toBeNull(); expect(row?.authId).toBe(MINTED); @@ -320,18 +433,38 @@ describe("a return leg this deployment will not complete", () => { */ test("coming back signed in as a different person refuses", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); - const response = await verify(STRANGER, "?flow_id=flow-abc"); - expect(response.status).toBe(400); + await expectRefusal( + await verify(STRANGER, "?flow_id=flow-abc"), + "no-attempt", + ); expect(confirmCalls).toEqual([]); - const [row] = await database - .select() - .from(pendingAuthorizations) - .where(eq(pendingAuthorizations.actorId, ACTOR)); + const row = await attemptRow(); expect(row?.completedAt).toBeNull(); expect(row?.authId).toBe(MINTED); await expectNothingWritten(); }); + + /** + * THE VENDOR NOT ANSWERING THE CONFIRMATION AT ALL. Nothing of its reply travels with the + * refusal — not its sentence, not its body, not the error — because a reply on this leg can carry + * an authorization url, which is a bearer capability. + */ + test("a vendor that will not answer the confirmation refuses and writes nothing", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + confirmAnswer = async () => { + throw new Error("vendor down: https://vendor.example/consent?secret=x"); + }; + + const response = await verify(ACTOR, "?flow_id=flow-abc"); + await expectRefusal(response, "vendor-unavailable"); + expect(response.headers.get("location")).not.toContain("vendor.example"); + + const row = await attemptRow(); + expect(row?.completedAt).toBeNull(); + expect(row?.outcome).toBeNull(); + await expectNothingWritten(); + }); }); describe("a return leg with no session", () => { @@ -353,37 +486,46 @@ describe("a return leg with no session", () => { }); describe("a return leg with a session whose role was removed", () => { + /** + * THE ONE EXIT WITH NO `Location`, AND IT IS STATED RATHER THAN INHERITED. The guard that refused + * here is the guard `/api/me` answers from, so this session cannot reliably load the landing page + * either: it would be sent to `/sign` or into an error boundary, which is the dead end this + * branch exists to avoid. + */ test("is told so with a 403, not sent to a sign-in that cannot help", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); const response = await verify(ACTOR, "?flow_id=flow-abc", { roles: [] }); expect(response.status).not.toBe(303); expect(response.status).toBe(403); expect(response.headers.get("location")).toBeNull(); + expect(await response.json()).toHaveProperty("error"); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); }); describe("a deployment whose broker runs no correlated authorization flow", () => { - test("a broker declaring authorizations: null answers 503", async () => { + test("a broker declaring authorizations: null refuses as not supported", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); - const response = await verify(ACTOR, "?flow_id=flow-abc", { - brokerBag: { - broker: { ...broker, authorizations: null }, - provider: "composio", - }, - }); - expect(response.status).toBe(503); + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc", { + brokerBag: { + broker: { ...broker, authorizations: null }, + provider: "composio", + }, + }), + "not-supported", + ); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); - test("no broker bag at all answers 503", async () => { + test("no broker bag at all refuses as no broker", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); - const response = await verify(ACTOR, "?flow_id=flow-abc", { - brokerBag: undefined, - }); - expect(response.status).toBe(503); + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc", { brokerBag: undefined }), + "no-broker", + ); expect(confirmCalls).toEqual([]); await expectNothingWritten(); }); @@ -393,6 +535,372 @@ describe("a deployment whose broker runs no correlated authorization flow", () = brokerBag: undefined, }); expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe( + `${APP_URL}/sign?flow_id=flow-abc`, + ); + }); +}); + +describe("what the authorization's own status decides", () => { + /** + * A TIMEOUT IS NEITHER A SUCCESS NOR A FAILURE, and the proof is that the attempt is still + * completable afterwards: the second call below finishes the very same row. A refusal that closed + * it would leave somebody who came back to the link with nothing to finish. + */ + test("still pending at the bound refuses and LEAVES THE ATTEMPT STANDING", async () => { + const createdAt = await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + statusAnswer = async () => ({ + status: "pending", + userId: ACTOR, + scopes: [], + }); + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "not-finished", + ); + // BOUNDED, and the bound is small: never the SDK's unbounded wait. + expect(statusCalls.length).toBeGreaterThan(1); + expect(statusCalls.length).toBeLessThanOrEqual(5); + expect(statusCalls.every((id) => id === MINTED)).toBe(true); + + const standing = await attemptRow(); + expect(standing?.completedAt).toBeNull(); + expect(standing?.outcome).toBeNull(); + expect(standing?.authId).toBe(MINTED); + expect(standing?.createdAt.getTime()).toBe(createdAt.getTime()); + await expectNothingWritten(); + + // AND IT COMPLETES AFTERWARDS, which is the whole claim. + statusAnswer = async () => ({ + status: "completed", + userId: ACTOR, + scopes: ["gmail.read"], + }); + const finished = await verify(ACTOR, "?flow_id=flow-abc"); + expect(finished.headers.get("location")).toBe(LANDING); + const after = await attemptRow(); + expect(after?.outcome).toBe("completed"); + expect(after?.completedAt).not.toBeNull(); + const { connections } = await written(); + expect(connections.length).toBe(1); + }, 30_000); + + test("a failed authorization refuses and the ROW is marked failed", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + statusAnswer = async () => ({ + status: "failed", + userId: ACTOR, + scopes: [], + }); + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "authorization-failed", + ); + expect(statusCalls).toEqual([MINTED]); + + const row = await attemptRow(); + expect(row?.outcome).toBe("failed"); + expect(row?.completedAt).not.toBeNull(); + expect(row?.authId).toBe(MINTED); + await expectNothingWritten(); + }); + + test("a status naming a different identity refuses and the ROW is marked failed", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + statusAnswer = async () => ({ + status: "completed", + userId: `somebody-else-${suite}`, + scopes: ["gmail.read"], + }); + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "not-your-authorization", + ); + + const row = await attemptRow(); + expect(row?.outcome).toBe("failed"); + expect(row?.completedAt).not.toBeNull(); + await expectNothingWritten(); + }); + + test("a vendor that will not answer the status refuses and leaves the row standing", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + statusAnswer = async () => { + throw new Error(`vendor down, token ${TOKEN}`); + }; + + const response = await verify(ACTOR, "?flow_id=flow-abc"); + await expectRefusal(response, "vendor-unavailable"); + expect(response.headers.get("location")).not.toContain(TOKEN); + + const row = await attemptRow(); + expect(row?.completedAt).toBeNull(); + expect(row?.outcome).toBeNull(); + await expectNothingWritten(); + }); +}); + +/** + * D28: a consent whose account context moved on is not completed into the present. + * + * The app is switched to Shared between the mint and the callback, which mints a fresh deployment + * identity. The attempt names the person's own identity, which is now retired. + */ +describe("a consent whose account context has moved on", () => { + /** The connection the CURRENT identity already holds, which this refusal must not touch. */ + async function giveCurrentAccountAConnection() { + await database.insert(brokeredConnections).values({ + provider: "arcade", + app: TOOLKIT, + holder: "deployment", + userId: null, + vendorUserId: SHARED_IDENTITY, + verified: true, + }); + } + + async function switchToShared() { + await database + .update(mcpServers) + .set({ accountMode: "shared", sharedVendorUserId: SHARED_IDENTITY }) + .where(eq(mcpServers.id, APP)); + } + + test("refuses, withdraws the RETIRED identity's grant and leaves the CURRENT account alone", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + await giveCurrentAccountAConnection(); + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "context-changed", + ); + + // THE RETIRED IDENTITY, AND ONLY IT. A connection is keyed on the identity, and the retired one + // is not the current one, so this cannot reach the account that is live now. + expect(revokeCalls).toEqual([ + { + account: { holder: "person", userId: ACTOR, vendorUserId: ACTOR }, + toolkit: TOOLKIT, + }, + ]); + + // AND THE CURRENT ACCOUNT'S ROW IS STILL THERE, untouched and still the deployment's. + const { connections, events } = await written(); + expect(connections.length).toBe(1); + expect(connections[0]?.holder).toBe("deployment"); + expect(connections[0]?.vendorUserId).toBe(SHARED_IDENTITY); + expect(events.length).toBe(0); + + const row = await attemptRow(); + expect(row?.outcome).toBe("failed"); + expect(row?.completedAt).not.toBeNull(); + }); + + test("a withdrawal this deployment could not make keeps the attempt as unreconciled", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + revokeAnswer = async () => { + throw new Error("the vendor refused the revoke"); + }; + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "context-changed", + ); + + // A grant we know about and could not end is worth a row somebody can act on. + const row = await attemptRow(); + expect(row?.outcome).toBe("unreconciled"); + expect(row?.completedAt).not.toBeNull(); + await expectNothingWritten(); + }); +}); + +describe("a return leg that completes", () => { + test("lands on the app's own page, built from the ATTEMPT ROW", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.status).toBe(303); + /* + * FROM THE ATTEMPT ROW'S `server_id`, NEVER FROM A `next_uri`: the vendor refuses every value + * at mint, and what `confirm_user` answers is the vendor's own success page rather than ours. + * No notice parameter either — the row it comes back to now reads Connected, which is the same + * news told by the thing it is news about. + */ + expect(response.headers.get("location")).toBe(LANDING); + expect(response.headers.get("location")).not.toContain("authorized="); + + const { connections, events } = await written(); + expect(connections.length).toBe(1); + expect(connections[0]?.userId).toBe(ACTOR); + expect(connections[0]?.verified).toBe(true); + expect(events.length).toBe(1); + // The action the attempt was for and the scopes the vendor granted, which is ALL of the + // vendor's answer that is written anywhere. + expect(events[0]?.payload).toMatchObject({ + action: ACTION, + scope: "gmail.read", + // THE BROKER'S ANSWER, NOT THE TOOLKIT NAME. The stub answers a provider that is deliberately + // nothing like `Gmail-…`, so a route deriving one from the other fails here. + authProvider: "google", + }); + + const row = await attemptRow(); + expect(row?.outcome).toBe("completed"); + expect(row?.completedAt).not.toBeNull(); + }); + + /** + * A VENDOR THAT DOES NOT SAY WHICH AUTH PROVIDER IT IS records that it did not say, and the + * consent still completes: the grant is already live at the vendor, and an audit label is not + * worth refusing it over. Guessing the toolkit name here is the thing the seam forbids. + */ + test("an auth provider the vendor will not name is recorded as unnamed, and still completes", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + authProviderAnswer = async () => null; + + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.headers.get("location")).toBe(LANDING); + + const { connections, events } = await written(); + expect(connections.length).toBe(1); + expect(events[0]?.payload).toMatchObject({ authProvider: "" }); + }); + + /** + * TWO CALLBACKS AT ONCE, with `Promise.all` and never a sequential replay: a replay proves only + * that the row was already consumed, while the race is what the conditional UPDATE exists for. + * The loser answers exactly as the winner does, so a duplicate is a no-op rather than an error + * somebody has to explain. + */ + test("two CONCURRENT callbacks write exactly one connection and one audit event", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + + const [first, second] = await Promise.all([ + verify(ACTOR, "?flow_id=flow-abc"), + verify(ACTOR, "?flow_id=flow-abc"), + ]); + + expect(first.status).toBe(303); + expect(second.status).toBe(303); + expect(first.headers.get("location")).toBe(LANDING); + expect(second.headers.get("location")).toBe(LANDING); + + const { connections, events } = await written(); + expect({ connections: connections.length, events: events.length }).toEqual({ + connections: 1, + events: 1, + }); + }); + + /** + * A FAILURE BETWEEN THE CONNECTION WRITE AND THE AUDIT WRITE PERSISTS NEITHER. + * + * Injected at the DATABASE HANDLE rather than at the store's `auditStore` option, because the + * audit write that has to be atomic with the connection write is the one bound to the + * transaction — the same reason `arcade-completion-transaction.integration.test.ts` gives. And it + * is driven through the route, so the route's own promise — no exit without a `Location` — is + * held to on the path where its own store throws. + */ + test("a failure between the two writes persists NEITHER, and still answers a Location", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + + const brokenAuditDatabase = new Proxy(database, { + get(target, property, receiver) { + if (property !== "transaction") { + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + } + return (work: (tx: unknown) => Promise) => + target.transaction((transaction) => + work( + new Proxy(transaction, { + get(txTarget, txProperty, txReceiver) { + if (txProperty === "insert") { + return (table: unknown) => { + if (table === auditEvents) + throw new Error("injected audit failure"); + return txTarget.insert(table as never); + }; + } + const value = Reflect.get(txTarget, txProperty, txReceiver); + return typeof value === "function" + ? value.bind(txTarget) + : value; + }, + }), + ), + ); + }, + }) as typeof database; + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc", { + database: brokenAuditDatabase, + }), + "not-recorded", + ); + + // BOTH TABLES, because "it answered" is not evidence that nothing was written. + await expectNothingWritten(); + + // And the attempt is still claimable, because its UPDATE was in the same transaction. + const row = await attemptRow(); + expect(row?.completedAt).toBeNull(); + expect(row?.outcome).toBeNull(); + }); + + /** + * SPEC D31. The vendor's own status reply carries a LIVE PROVIDER ACCESS TOKEN and the person's + * full profile beside the three fields the seam narrows it to. A status response LOOKS LIKE + * METADATA, which is how the throwaway harness that proved this flow came to write the token to a + * file on its first run. So this case hands the route a reply carrying one and requires that it + * reaches no log line, no audit payload, no connection row and no `Location`. + */ + test("nothing the status answered reaches a log line or an audit row", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + statusAnswer = async () => + ({ + status: "completed", + userId: ACTOR, + scopes: ["gmail.read"], + token: TOKEN, + context: { token: TOKEN }, + user_info: { email: "person@example.test" }, + }) as never; + + const said: string[] = []; + const before = globalThis.console; + const hear = (...args: unknown[]) => + said.push(args.map((arg) => String(arg)).join(" ")); + globalThis.console = { + ...before, + log: hear, + info: hear, + warn: hear, + error: hear, + debug: hear, + } as typeof console; + let response: Response; + try { + response = await verify(ACTOR, "?flow_id=flow-abc"); + } finally { + globalThis.console = before; + } + + expect(response.status).toBe(303); + expect(said.join("\n")).not.toContain(TOKEN); + expect(response.headers.get("location")).not.toContain(TOKEN); + + const { connections, events } = await written(); + expect(JSON.stringify(events)).not.toContain(TOKEN); + expect(JSON.stringify(events)).not.toContain("person@example.test"); + expect(JSON.stringify(connections)).not.toContain(TOKEN); }); }); From 898ceee56203c661f6736dfcc385676602af02ff Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:55:49 +0200 Subject: [PATCH 076/232] fix(arcade): the directory sentences name the active broker, not Composio The client shows the server's error in preference to its own fallback, so the three sentences the Arcade directory screen renders (directory read, enable-path failure, not-listed refusal) told an Arcade operator to check COMPOSIO_API_KEY. Vendor and env var now come off BROKERED_PROFILE; Composio's rendering is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/access.ts | 10 +- server/src/plugins/routes.ts | 30 ++-- .../tests/directory-vendor-sentences.test.ts | 130 ++++++++++++++++++ 3 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 server/tests/directory-vendor-sentences.test.ts diff --git a/server/src/plugins/access.ts b/server/src/plugins/access.ts index e896d782d..bb911cfa9 100644 --- a/server/src/plugins/access.ts +++ b/server/src/plugins/access.ts @@ -290,14 +290,20 @@ const TOOLKIT_OF: Record string | null> = { */ export const BROKERED_PROFILE: Record< BrokerProvider, - { scheme: string; idPrefix: string; vendor: string } + { scheme: string; idPrefix: string; vendor: string; envVar: string } > = { composio: { scheme: "composio://", idPrefix: "composio-", vendor: "Composio", + envVar: "COMPOSIO_API_KEY", + }, + arcade: { + scheme: "arcade://", + idPrefix: "arcade-", + vendor: "Arcade", + envVar: "ARCADE_API_KEY", }, - arcade: { scheme: "arcade://", idPrefix: "arcade-", vendor: "Arcade" }, }; /** diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 7bcd4c1d0..b3129fd64 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -6,7 +6,7 @@ import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; import { reasonWithoutStatement } from "../db/query-failure"; import type { AccountModeSwitch } from "./account-mode"; -import { type BrokerProvider, toolkitOfFor } from "./access"; +import { BROKERED_PROFILE, type BrokerProvider, toolkitOfFor } from "./access"; import { normalizeArcadeError } from "./arcade-adapter"; import { type AuthorizationFlow, @@ -110,15 +110,21 @@ function brokerRefusal( } /** - * What a reader is told when Composio would not answer with its directory. + * What a reader is told when the active broker would not answer with its directory. * - * One constant because two routes make that call — browsing the catalogue and enabling an app out of + * One function because two routes make that call — browsing the catalogue and enabling an app out of * it — and a reader meeting the same failure through two doors should not meet two sentences. It * names the setting rather than quoting it: a key that was pasted with a space in it, or one for - * another project, is the likeliest reason Composio will not talk to this deployment at all. + * another project, is the likeliest reason the vendor will not talk to this deployment at all. + * + * THE VENDOR AND ITS SETTING COME OFF THE ACTIVE PROVIDER'S PROFILE, because the client shows the + * server's sentence in preference to its own fallback: a sentence that spelled one broker was read + * by an operator of the other. */ -const DIRECTORY_UNAVAILABLE = - "Composio would not answer with its app directory, and said nothing about why. Check that COMPOSIO_API_KEY is this project's key, and check Composio's status if it persists."; +function directoryUnavailable(provider: BrokerProvider): string { + const { vendor, envVar } = BROKERED_PROFILE[provider]; + return `${vendor} would not answer with its app directory, and said nothing about why. Check that ${envVar} is this project's key, and check ${vendor}'s status if it persists.`; +} /** * Where a browser with no session is sent when it lands on the verifier, carrying what it arrived @@ -928,7 +934,10 @@ export function createPluginRoutes( } catch (error) { // The vendor's own sentence where there is one, because "invalid api key" is the diagnosis // and a 500 is not. See {@link brokerRefusal} for what is kept out of the answer. - const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + const refusal = brokerRefusal( + error, + directoryUnavailable(activeProvider), + ); return context.json({ error: refusal.error }, refusal.status); } /* @@ -1035,7 +1044,10 @@ export function createPluginRoutes( * deployment whose key is wrong was told nothing at all, on the one screen where the key * had just been set. */ - const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + const refusal = brokerRefusal( + error, + directoryUnavailable(activeProvider), + ); return context.json({ error: refusal.error }, refusal.status); } } @@ -1044,7 +1056,7 @@ export function createPluginRoutes( return context.json( { error: slug - ? `${slug} is not an app Composio lists for this deployment.` + ? `${slug} is not an app ${BROKERED_PROFILE[activeProvider].vendor} lists for this deployment.` : "An app is required.", }, 400, diff --git a/server/tests/directory-vendor-sentences.test.ts b/server/tests/directory-vendor-sentences.test.ts new file mode 100644 index 000000000..8a2293c53 --- /dev/null +++ b/server/tests/directory-vendor-sentences.test.ts @@ -0,0 +1,130 @@ +// server/tests/directory-vendor-sentences.test.ts +/** + * The three sentences the Arcade directory screen renders, in the active broker's own name. + * + * `apps.error.message` wins over the client's fallback, so a server sentence that spells Composio + * is what an Arcade operator reads. The directory read, the enable path's directory failure and its + * not-listed refusal each name the vendor and (for the first two) its key setting; the vendor and + * the setting come off the active provider's profile. + * + * THE COMPOSIO RENDERINGS ARE ASSERTED AS EXACT STRINGS. A regex would pass against a reworded + * sentence, and rewording a Composio sentence is the one thing this change must not do. + */ +import { describe, expect, test } from "bun:test"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { ComposioBroker } from "../src/plugins/broker"; +import { createPluginRoutes } from "../src/plugins/routes"; +import type { PluginStore } from "../src/plugins/store"; + +function deployment(provider: BrokerProvider, listApps: () => Promise) { + const broker = { + grantIsLocal: true, + authProviderOf: async () => null, + authorizations: null, + authorize: async () => { + throw new Error("never asked"); + }, + listApps, + } as unknown as ComposioBroker; + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: "admin-1", + email: "admin@example.test", + role: "admin", + } as never); + await next(); + }; + const app = new Hono().route( + "/api/plugins", + createPluginRoutes( + { serverUrls: async () => [] } as unknown as PluginStore, + signedIn, + async () => true, + { + publicUrl: "https://openbot.example", + appUrl: "https://app.example", + encryptionKey: `${"A".repeat(43)}=`, + personHasAccess: async () => true, + }, + { broker, provider }, + ), + ); + const url = "http://openbot.test/api/plugins/composio/apps"; + return { + read: () => app.request(url), + enable: (slug: string) => + app.request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug }), + }), + }; +} + +const down = async (): Promise => { + throw new Error("socket hang up"); +}; +const empty = async (): Promise => [] as never; + +const EXPECTED: Record< + BrokerProvider, + { unavailable: string; notListed: string } +> = { + composio: { + unavailable: + "Composio would not answer with its app directory, and said nothing about why. Check that COMPOSIO_API_KEY is this project's key, and check Composio's status if it persists.", + notListed: "nope is not an app Composio lists for this deployment.", + }, + arcade: { + unavailable: + "Arcade would not answer with its app directory, and said nothing about why. Check that ARCADE_API_KEY is this project's key, and check Arcade's status if it persists.", + notListed: "nope is not an app Arcade lists for this deployment.", + }, +}; + +for (const provider of ["composio", "arcade"] as const) { + describe(`a ${provider} deployment names its own vendor`, () => { + test("the directory read", async () => { + const response = await deployment(provider, down).read(); + expect(response.status).toBe(502); + expect(((await response.json()) as { error: string }).error).toBe( + EXPECTED[provider].unavailable, + ); + }); + test("the enable path when the directory is down", async () => { + const response = await deployment(provider, down).enable("gmail"); + expect(response.status).toBe(502); + expect(((await response.json()) as { error: string }).error).toBe( + EXPECTED[provider].unavailable, + ); + }); + test("the enable path when the app is not listed", async () => { + const response = await deployment(provider, empty).enable("nope"); + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toBe( + EXPECTED[provider].notListed, + ); + }); + }); +} + +describe("an Arcade operator is never told about Composio", () => { + test("none of the three sentences mentions it", async () => { + const it = deployment("arcade", down); + const none = deployment("arcade", empty); + const bodies = [ + await (await it.read()).text(), + await (await it.enable("gmail")).text(), + await (await none.enable("nope")).text(), + ]; + for (const body of bodies) { + expect(body).toMatch(/Arcade/); + expect(body).not.toMatch(/composio/i); + } + }); +}); From 7d2f1dee48fd68651bcaeb1743f8cea1f0915da1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 16:56:12 +0200 Subject: [PATCH 077/232] feat(arcade): disconnect revokes, deletes and audits every app the revocation ended disconnectBrokered now finds the apps that share the asked app's connection (same vendor identity AND auth provider, via resolveSiblings/connectionSiblings), revokes ONCE at the vendor before touching anything local, then deletes the local row of EVERY app that ended and writes an audit row for each. A partial vendor failure throws before any delete or audit write, leaving every row standing so pressing Disconnect again is the recovery. Composio is unchanged: each toolkit is its own auth provider there, so the sibling set is empty. Every audit row carries `unansweredSiblings`, an integer: how many other apps on the identity the auth-provider resolver could not answer for (left out, never guessed, never named). Sibling rows also carry `endedWith`. Published signature (micro-tasks 10.2 and 11.1 consume this): siblingApps(input: { toolkit: string; account: AccountRef }): Promise<{ siblings: string[]; unanswered: number }> was Promise. connectionSiblings is unchanged; the vendorUserId pre-filter stays in the WHERE. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 191 ++++++++++++------ .../tests/arcade-disconnect-cascade.test.ts | 185 +++++++++++++++++ server/tests/arcade-siblings.test.ts | 10 +- 3 files changed, 323 insertions(+), 63 deletions(-) create mode 100644 server/tests/arcade-disconnect-cascade.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 3522ce5fd..7df296c04 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2930,6 +2930,67 @@ export function createPluginStore(options: PluginStoreOptions) { return schemeKind(await brokeredAppScheme(toolkit)); } + /** + * THE ONE PLACE THAT ASKS THE BROKER FOR AN AUTH PROVIDER, and so the only place that knows how + * many it could not get. {@link connectionSiblings} says what the answers imply; this is the + * question asked of the rows this deployment holds, with its one extra fact: HOW MANY APPS ON + * THE ASKING IDENTITY WENT UNANSWERED. + * + * THE COUNT EXISTS BECAUSE THE RULE IS ASYMMETRIC ON PURPOSE. An app the broker cannot answer for + * is left out rather than guessed at — naming an app that will not be touched trains people to + * dismiss the warning that matters (D26). But under-naming is not free: somebody presses + * Disconnect and loses an app nobody warned them about, and a deployment whose resolver fails + * systematically would do it silently and for ever. So the number is surfaced, an integer and + * never names, for whoever reads the trail. + * + * Counted over the OTHER apps on the asking identity: the asking app's own row is not a sibling + * candidate. If the asking app itself is unanswered, nothing is resolvable and every other app on + * the identity is unanswered with it. + */ + async function resolveSiblings(input: { + toolkit: string; + account: AccountRef; + }): Promise<{ siblings: string[]; unanswered: number }> { + if (!broker) return { siblings: [], unanswered: 0 }; + const authProvider = await broker.authProviderOf(input.toolkit); + const held = await database + .select({ + app: brokeredConnections.app, + vendorUserId: brokeredConnections.vendorUserId, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, brokerProvider), + eq(brokeredConnections.vendorUserId, input.account.vendorUserId), + ), + ); + if (!authProvider) { + const others = new Set( + held.map((row) => row.app).filter((app) => app !== input.toolkit), + ); + return { siblings: [], unanswered: others.size }; + } + const footprints: ConnectionFootprint[] = []; + const unanswered = new Set(); + for (const row of held) { + const rowProvider = await broker.authProviderOf(row.app); + if (rowProvider) footprints.push({ ...row, authProvider: rowProvider }); + else if (row.app !== input.toolkit) unanswered.add(row.app); + } + return { + siblings: connectionSiblings( + { + app: input.toolkit, + vendorUserId: input.account.vendorUserId, + authProvider, + }, + footprints, + ), + unanswered: unanswered.size, + }; + } + return { accountRefFor, /** @@ -6086,41 +6147,17 @@ export function createPluginStore(options: PluginStoreOptions) { * sibling that is not one is the failure D26 exists to prevent. If the asking app itself is * unanswered the list is empty. * + * `unanswered` IS HOW MANY OTHER APPS ON THE ASKING IDENTITY WERE LEFT OUT FOR THAT REASON — an + * integer, never names; see {@link resolveSiblings} for why it is surfaced. + * * IT ANSWERS A FACT AND ACTS ON NOTHING: no revocation, no deletion, no audit write. Returns the - * sibling toolkits, sorted, never including `toolkit`. + * sibling toolkits, sorted, never including `toolkit`, and the unanswered count. */ async siblingApps(input: { toolkit: string; account: AccountRef; - }): Promise { - if (!broker) return []; - const authProvider = await broker.authProviderOf(input.toolkit); - if (!authProvider) return []; - const held = await database - .select({ - app: brokeredConnections.app, - vendorUserId: brokeredConnections.vendorUserId, - }) - .from(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, brokerProvider), - eq(brokeredConnections.vendorUserId, input.account.vendorUserId), - ), - ); - const footprints: ConnectionFootprint[] = []; - for (const row of held) { - const rowProvider = await broker.authProviderOf(row.app); - if (rowProvider) footprints.push({ ...row, authProvider: rowProvider }); - } - return connectionSiblings( - { - app: input.toolkit, - vendorUserId: input.account.vendorUserId, - authProvider, - }, - footprints, - ); + }): Promise<{ siblings: string[]; unanswered: number }> { + return resolveSiblings(input); }, /** @@ -7780,47 +7817,85 @@ export function createPluginStore(options: PluginStoreOptions) { unreadable: "reports it as asked for too, which is the claim that cannot be too weak"; } >; - const fieldScheme = (await brokeredAppKind(input.toolkit)) === "key"; + /* + * THE APPS THIS REVOCATION ENDS BESIDES THE ONE ASKED ABOUT, read BEFORE anything is revoked. + * Where a connection is keyed `(identity, auth provider)` — Arcade — withdrawing one app's + * connection ends every app that shares it, so a local row left for one of them claims an + * account the vendor no longer holds, which is the other way to lie. Where each app is its + * own auth provider — Composio — this is always empty and nothing below differs from a + * disconnect of one. The question is {@link resolveSiblings}' and none of it is derived here. + */ + const { siblings, unanswered } = await resolveSiblings({ + toolkit: input.toolkit, + account: input.account, + }); + + // Read while the rows are still here: the scheme is on the row, and the delete below ends it. + const keyApps = new Set(); + for (const app of [input.toolkit, ...siblings]) { + if ((await brokeredAppKind(app)) === "key") keyApps.add(app); + } // Whether there was an account to end at all, which is what decides if anybody was // disconnected. Named apart from the field below because for a key the two differ: something // ended, and nothing was asked of the provider. + // + // REVOKE BEFORE ANY LOCAL DELETE, and ONE ask: it ends the siblings with it. It THROWS on a + // partial ask, which is the partial failure — the throw leaves this method before a single + // row is deleted or written, every app's row standing, so pressing Disconnect again is the + // recovery. const ended = await broker.revoke({ account: input.account, toolkit: input.toolkit, }); - const vendorRevocationRequested = fieldScheme ? false : ended; + // Every app that ended, the asked one first. Its audit row is its own; a sibling's names the + // app whose disconnect ended it. + for (const app of [input.toolkit, ...siblings]) { + const vendorRevocationRequested = keyApps.has(app) ? false : ended; - // `returning` because whether a row was here is half of what decides if anybody was - // disconnected, and a delete that answered nothing would leave the two cases indistinguishable. - const [deleted] = await database - .delete(brokeredConnections) - .where(accountRow(input.toolkit, input.account)) - .returning({ app: brokeredConnections.app }); + // `returning` because whether a row was here is half of what decides if anybody was + // disconnected, and a delete that answered nothing would leave the two cases + // indistinguishable. + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(app, input.account)) + .returning({ app: brokeredConnections.app }); - if (deleted || ended) { - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: input.toolkit, - payload: { - actor: input.by, - server: input.toolkit, - // Whose account this was, which is not always who ended it: an administrator - // offboarding somebody and a person disconnecting themselves write the same shape of - // row, and only these two fields tell them apart. The deployment's own account has no - // person behind it, so it is named as the deployment rather than as whoever pressed. - owner: - input.account.holder === "person" - ? input.account.userId - : "deployment", - reason: input.reason, - vendorRevocationRequested, - }, - }); + if (deleted || ended) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: app, + payload: { + actor: input.by, + server: app, + // Whose account this was, which is not always who ended it: an administrator + // offboarding somebody and a person disconnecting themselves write the same shape of + // row, and only these two fields tell them apart. The deployment's own account has + // no person behind it, so it is named as the deployment rather than as whoever + // pressed. + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + vendorRevocationRequested, + /* + * HOW MANY apps on this identity could not be asked about and so were left out of + * the set above, never their names. An integer, so an operator can see that "we did + * not know" happened; see {@link resolveSiblings}. + */ + unansweredSiblings: unanswered, + ...(app === input.toolkit ? {} : { endedWith: input.toolkit }), + }, + }); + } } + const vendorRevocationRequested = keyApps.has(input.toolkit) + ? false + : ended; return { vendorRevocationRequested }; }, diff --git a/server/tests/arcade-disconnect-cascade.test.ts b/server/tests/arcade-disconnect-cascade.test.ts new file mode 100644 index 000000000..f3491f229 --- /dev/null +++ b/server/tests/arcade-disconnect-cascade.test.ts @@ -0,0 +1,185 @@ +// server/tests/arcade-disconnect-cascade.test.ts +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { auditEvents, brokeredConnections } from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const gmail = `Gmail-${suite}`; +const calendar = `GoogleCalendar-${suite}`; +const slack = `Slack-${suite}`; +const drive = `GoogleDrive-${suite}`; +const lonely = `Lonely-${suite}`; + +const providerOf: Record = { + [gmail]: "google", + [calendar]: "google", + [drive]: null, // the resolver cannot answer for this one + [slack]: "slack", + [lonely]: "google", +}; + +/** What the vendor was asked to revoke, in order, and whether it should refuse. */ +const revoked: string[] = []; +let refuseRevoke = false; + +const broker: ConnectedAppBroker = { + grantIsLocal: false, + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + authProviderOf: async (toolkit) => providerOf[toolkit] ?? null, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: "ar_1", + }), + isConnected: async () => true, + revoke: async ({ toolkit }) => { + revoked.push(toolkit); + if (refuseRevoke) + throw new Error("Arcade revoke failed for 1 of 2 connections."); + return true; + }, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme", +}; + +const store = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, +}); + +const seeded: string[] = []; +async function hold(app: string, userId: string) { + seeded.push(app); + await database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder: "person", + userId, + vendorUserId: userId, + }); +} +const rowsFor = async (apps: string[]) => + ( + await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where(inArray(brokeredConnections.app, apps)) + .orderBy(asc(brokeredConnections.app)) + ).map((row) => row.app); +// Filed by owner, because the trail is append-only and an earlier test's rows are still there. +const trailFor = (userId: string) => + database + .select({ + app: auditEvents.targetId, + payload: sql>`payload`, + }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_disconnected"), + sql`payload ->> 'owner' = ${userId}`, + ), + ) + .orderBy(asc(auditEvents.targetId)); + +// The audit trail is append-only, so it is never cleaned: every name here carries this run's suffix. +afterEach(async () => { + if (seeded.length) { + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, seeded)); + } + seeded.length = 0; + revoked.length = 0; + refuseRevoke = false; +}); + +const disconnect = (userId: string, toolkit: string) => + store.disconnectBrokered({ + toolkit, + account: { holder: "person", userId, vendorUserId: userId }, + by: userId, + reason: "self", + }); + +describe("the disconnect cascade", () => { + test("a disconnect whose siblings are two apps removes and audits BOTH local rows", async () => { + const userId = `person-${suite}-a`; + await hold(gmail, userId); + await hold(calendar, userId); + await hold(slack, userId); // another provider: untouched + + await disconnect(userId, gmail); + + expect(await rowsFor([gmail, calendar, slack])).toEqual([slack]); + const trail = await trailFor(userId); + expect(trail.map((event) => event.app)).toEqual([calendar, gmail].sort()); + }); + + test("a partial vendor failure THROWS and every local row survives, with no audit row", async () => { + const userId = `person-${suite}-b`; + await hold(gmail, userId); + await hold(calendar, userId); + refuseRevoke = true; + + await expect(disconnect(userId, gmail)).rejects.toThrow(/revoke failed/); + + expect(await rowsFor([gmail, calendar])).toEqual([calendar, gmail].sort()); + expect(await trailFor(userId)).toEqual([]); + }); + + test("a disconnect with no siblings removes and audits exactly the one app", async () => { + const userId = `person-${suite}-c`; + await hold(lonely, userId); + await hold(slack, userId); + + await disconnect(userId, lonely); + + expect(await rowsFor([lonely, slack])).toEqual([slack]); + const trail = await trailFor(userId); + expect(trail.map((event) => event.app)).toEqual([lonely]); + }); + + test("the audit payload carries the unanswered count, an integer and never names", async () => { + const userId = `person-${suite}-d`; + await hold(gmail, userId); + await hold(calendar, userId); + await hold(drive, userId); // its auth provider cannot be resolved + + await disconnect(userId, gmail); + + // The unanswered app is NOT touched (left out, never guessed)... + expect(await rowsFor([gmail, calendar, drive])).toEqual([drive]); + // ...and every row written says that one app was not known. + const trail = await trailFor(userId); + expect(trail).toHaveLength(2); + for (const event of trail) { + expect(event.payload.unansweredSiblings).toBe(1); + expect(JSON.stringify(event.payload)).not.toContain(drive); + } + }); +}); diff --git a/server/tests/arcade-siblings.test.ts b/server/tests/arcade-siblings.test.ts index 1766db87e..208994c7a 100644 --- a/server/tests/arcade-siblings.test.ts +++ b/server/tests/arcade-siblings.test.ts @@ -156,7 +156,7 @@ describe("revocation siblings are keyed on (vendor identity, auth provider)", () toolkit: gmail, account: { holder: "deployment", vendorUserId: gmailIdentity }, }), - ).toEqual([]); + ).toEqual({ siblings: [], unanswered: 0 }); }); test("two apps genuinely sharing the pair are siblings, and an app on another provider is not", async () => { @@ -170,7 +170,7 @@ describe("revocation siblings are keyed on (vendor identity, auth provider)", () toolkit: gmail, account: { holder: "person", userId, vendorUserId: userId }, }), - ).toEqual([calendar]); + ).toEqual({ siblings: [calendar], unanswered: 0 }); }); test("an app with no co-tenant has an empty list, never itself", async () => { @@ -182,7 +182,7 @@ describe("revocation siblings are keyed on (vendor identity, auth provider)", () toolkit: lonely, account: { holder: "person", userId, vendorUserId: userId }, }), - ).toEqual([]); + ).toEqual({ siblings: [], unanswered: 0 }); }); /** @@ -206,7 +206,7 @@ describe("revocation siblings are keyed on (vendor identity, auth provider)", () toolkit: gmail, account: { holder: "person", userId: mine, vendorUserId: mine }, }), - ).toEqual([calendar]); + ).toEqual({ siblings: [calendar], unanswered: 0 }); // The asking toolkit, plus its own two rows. Never `slack`, never `lonely`. expect(asked.toSorted()).toEqual([calendar, gmail, gmail].toSorted()); @@ -248,6 +248,6 @@ describe("siblingApps under Composio", () => { toolkit: gmail, account: { holder: "person", userId, vendorUserId: userId }, }), - ).toEqual([]); + ).toEqual({ siblings: [], unanswered: 0 }); }); }); From b4bb8fe2be54f22d18ba83e5875eb1db10c6d310 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:01:22 +0200 Subject: [PATCH 078/232] fix(arcade): the D28 withdrawal reads revoke's answer, and a withdrawn attempt says so revoke() answers false when connectionsFor found nothing, and since 19c9c52b that includes an unresolved auth provider. The route dropped the boolean and recorded withdrawn = true, closing the attempt as settled with a grant that may still be live. A false now takes the not-withdrawn branch as a throw does. The outcome column gains a fourth value, withdrawn: the consent completed at the vendor and this deployment refused and ended it, which is neither a decline (failed) nor an unknown (unreconciled). Type widening only: outcome is plain text with no CHECK constraint, so no migration. Mapping: drift + withdrawal succeeded -> withdrawn; drift + throw, not attempted, or false -> unreconciled; status failed and identity mismatch -> failed (unchanged). Tests assert the attempt ROW's outcome, completed_at and auth_id. Every "nothing written" assertion is scoped to this test alone: the attempt row is keyed on the suite-unique ACTOR (cleared in beforeEach) and the toolkit is unique per test (Gmail--), so another test cannot supply the zero. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/db/schema/plugins.ts | 3 ++- server/src/plugins/routes.ts | 14 ++++++++++---- server/src/plugins/store.ts | 15 ++++++++++++++- server/tests/arcade-verify.test.ts | 26 +++++++++++++++++++++++++- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 1093eebfa..bd502b282 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -473,7 +473,8 @@ export const pendingAuthorizations = pgTable("pending_authorizations", { authId: text("auth_id"), createdAt: createdAt(), completedAt: timestamp("completed_at", { withTimezone: true }), - outcome: text("outcome").$type<"completed" | "failed" | "unreconciled">(), + outcome: text("outcome") + .$type<"completed" | "failed" | "unreconciled" | "withdrawn">(), }); /** diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index b3129fd64..5cac5295f 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2767,7 +2767,8 @@ export function createPluginRoutes( * before this deployment looked. So the RETIRED identity's connection is withdrawn — keyed on * that identity and on this app, which CANNOT TOUCH THE CURRENT ACCOUNT: a connection is keyed * `(user_id, provider_id)` and the retired identity is not the current one. Where that - * withdrawal fails the attempt is kept as `unreconciled` rather than closed, because a grant + * withdrawal succeeds the attempt is marked `withdrawn`; where it fails, is not attempted or + * answers `false` it is kept as `unreconciled` rather than closed, because a grant * we know about and could not end is worth a row somebody can act on. */ const retired: AccountRef = @@ -2785,18 +2786,23 @@ export function createPluginRoutes( let withdrawn = false; if (retiredApp) { try { - await composio.broker.revoke({ + /* + * THE BOOLEAN IS READ, NOT DROPPED. `false` used to mean only "nothing there", but the + * lifecycle trio filters on the RESOLVED auth provider, so it now also means "we could + * not work out what to withdraw". Over a possibly-live grant the safe reading of that + * ambiguity is "we do not know": it takes the not-withdrawn branch, as a throw does. + */ + withdrawn = await composio.broker.revoke({ account: retired, toolkit: retiredApp, }); - withdrawn = true; } catch { // Nothing from the vendor's answer travels with this, here as everywhere on this leg. withdrawn = false; } } if (withdrawn) - await store.failAuthorizationAttempt({ actorId, authId }); + await store.markAuthorizationWithdrawn({ actorId, authId }); else await store.markAuthorizationUnreconciled({ actorId, authId }); return refusedWith("context-changed"); } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 7df296c04..9605aae0c 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1639,7 +1639,7 @@ export function createPluginStore(options: PluginStoreOptions) { */ const closeAuthorizationAttempt = async ( input: { actorId: string; authId: string }, - outcome: "failed" | "unreconciled", + outcome: "failed" | "unreconciled" | "withdrawn", ) => { const written = await database .update(pendingAuthorizations) @@ -6456,6 +6456,19 @@ export function createPluginStore(options: PluginStoreOptions) { return await closeAuthorizationAttempt(input, "unreconciled"); }, + /** + * Mark an attempt whose consent completed at the vendor and which this deployment then refused + * and ended by withdrawing the grant — neither a decline nor an unknown. An operator seeing a + * run of these is seeing apps whose mode is being switched inside a consent window, which is a + * different problem from people declining. + */ + async markAuthorizationWithdrawn(input: { + actorId: string; + authId: string; + }): Promise { + return await closeAuthorizationAttempt(input, "withdrawn"); + }, + /** * The live attempt for a session subject (`users.id`, never `credentialActorId`), or null when * there is none, it is past the 15-minute TTL, or it has completed. diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index c44f12506..0e2b23f27 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -695,9 +695,32 @@ describe("a consent whose account context has moved on", () => { expect(connections[0]?.vendorUserId).toBe(SHARED_IDENTITY); expect(events.length).toBe(0); + // THE CONSENT COMPLETED AND THIS DEPLOYMENT ENDED IT: neither a decline nor an unknown. const row = await attemptRow(); - expect(row?.outcome).toBe("failed"); + expect(row?.outcome).toBe("withdrawn"); + expect(row?.completedAt).not.toBeNull(); + expect(row?.authId).toBe(MINTED); + }); + + test("a revoke that answers false, which an unresolved auth provider does, is not a withdrawal", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + revokeAnswer = async () => false; + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "context-changed", + ); + + // `false` is "nothing found OR could not work out what to withdraw"; a possibly-live grant + // reads that as "we do not know". Scoped to this test: the row is keyed on ACTOR, which + // beforeEach clears, and TOOLKIT is unique per test. + const row = await attemptRow(); + expect(revokeCalls.length).toBe(1); + expect(row?.outcome).toBe("unreconciled"); expect(row?.completedAt).not.toBeNull(); + expect(row?.authId).toBe(MINTED); + await expectNothingWritten(); }); test("a withdrawal this deployment could not make keeps the attempt as unreconciled", async () => { @@ -716,6 +739,7 @@ describe("a consent whose account context has moved on", () => { const row = await attemptRow(); expect(row?.outcome).toBe("unreconciled"); expect(row?.completedAt).not.toBeNull(); + expect(row?.authId).toBe(MINTED); await expectNothingWritten(); }); }); From 407c474c364f736fd10a370470216a7b1c97c9b6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:01:52 +0200 Subject: [PATCH 079/232] feat(arcade): carry flow_id through sign-in so the documented recovery completes The verifier sends a session-less arrival to /sign?flow_id=..., and signInUrlFor promises that signing back in resumes it. Nothing read the handle, so it was dropped. - /sign gains validateSearch for an optional flow_id (key omitted when absent, as connected-accounts does for ?connected=, so existing Links compile unchanged). - resumeUrlFor(origin, flowId) is the one decision: the verifier path with the handle encoded, or the origin when there is none. The handle is a value, never a destination: it is encoded into a path built here on this origin, there is no next= parameter, and it is length-capped (over the cap counts as no handle). - signInWith and signInWithEmailDomain take the callback URL as a trailing parameter defaulting to window.location.origin; other callers are unchanged. - An already-signed-in arrival with a handle is redirected to the verifier via redirect({ href }) (an API path, not a router route); without one, to /. Better Auth's trusted-origin check is unaffected: the callback is still on the app's own origin, differing from before only in path and query, and the check compares origins. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/auth/client.ts | 12 +-- app/src/lib/auth/resume.ts | 50 ++++++++++++ app/src/routes/sign.tsx | 34 +++++++- app/tests/sign-resume.test.tsx | 140 +++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 app/src/lib/auth/resume.ts create mode 100644 app/tests/sign-resume.test.tsx diff --git a/app/src/lib/auth/client.ts b/app/src/lib/auth/client.ts index ebf986663..6999904eb 100644 --- a/app/src/lib/auth/client.ts +++ b/app/src/lib/auth/client.ts @@ -36,11 +36,12 @@ export async function signInWith( callbackURL: string; }) => Promise = (input) => authClient.signIn.social(input as never) as Promise, + // Where the browser returns to. Defaults to the origin, which is what every caller without + // somewhere particular to resume has always got. Same origin as the app, so Better Auth's + // trusted-origin check treats it as it treated the bare origin. + callbackURL: string = window.location.origin, ) { - const result = await start({ - provider, - callbackURL: window.location.origin, - }); + const result = await start({ provider, callbackURL }); if (result.error) { // Naming the provider matters more with three buttons than it did with one: "Could not start @@ -72,8 +73,9 @@ export async function signInWithEmailDomain( signIn: { sso: (i: unknown) => Promise }; } ).signIn.sso(input), + callbackURL: string = window.location.origin, ) { - const result = await start({ email, callbackURL: window.location.origin }); + const result = await start({ email, callbackURL }); if (result.error) { throw new Error( diff --git a/app/src/lib/auth/resume.ts b/app/src/lib/auth/resume.ts new file mode 100644 index 000000000..47ce4b50b --- /dev/null +++ b/app/src/lib/auth/resume.ts @@ -0,0 +1,50 @@ +/** + * Where somebody goes once they are signed in, when they were sent to sign-in carrying a handle. + * + * This is the return half of the decision `signInUrlFor` makes on the way out (server/src/plugins/ + * routes.ts): the verifier sends a browser with no session here carrying its `flow_id`, and this is + * what lets signing back in as the same person pick that flow up where it was left. + * + * THE HANDLE IS A VALUE, NEVER A DESTINATION. `flow_id` arrives in a URL and is therefore somebody + * else's input. It is encoded into a path this code builds, on an origin this code was given. It is + * never used as a redirect target, never concatenated into one, and there is no `next=`: a return + * address somebody else gets a say in is an open redirect, and a handle that can only ever be a + * query value on this deployment's own verifier cannot be pointed anywhere else. + */ + +/** The route that resumes a flow. This deployment's own, on its own origin. */ +const VERIFIER_PATH = "/api/plugins/arcade/verify"; + +/** + * Longer than any handle the server issues, short enough that a crafted link cannot make the + * callback URL arbitrarily large. Over the cap the value is not truncated: a truncated handle is a + * different handle, so it is treated as no handle at all. + */ +const MAX_FLOW_ID_LENGTH = 256; + +function usable(flowId: string | undefined): flowId is string { + return ( + typeof flowId === "string" && + flowId.length > 0 && + flowId.length <= MAX_FLOW_ID_LENGTH + ); +} + +/** The verifier's path with the handle as a query value, or null when there is no usable handle. */ +export function verifierPathFor(flowId: string | undefined): string | null { + return usable(flowId) + ? `${VERIFIER_PATH}?flow_id=${encodeURIComponent(flowId)}` + : null; +} + +/** + * Where sign-in should send the browser back to: the verifier carrying the handle, or the origin + * itself when there is none. Pure, so the resume is pinned without a DOM, a router or Better Auth. + */ +export function resumeUrlFor( + origin: string, + flowId: string | undefined, +): string { + const path = verifierPathFor(flowId); + return path === null ? origin : `${origin}${path}`; +} diff --git a/app/src/routes/sign.tsx b/app/src/routes/sign.tsx index 1d03a7afe..5202836e9 100644 --- a/app/src/routes/sign.tsx +++ b/app/src/routes/sign.tsx @@ -12,6 +12,7 @@ import { signInWith, signInWithEmailDomain, } from "@/lib/auth/client"; +import { resumeUrlFor, verifierPathFor } from "@/lib/auth/resume"; import { appConfig } from "@/lib/generated/application-config"; import { type AuthProviderId, @@ -26,12 +27,28 @@ const ENTRANCE_STAGGER_SECONDS = 0.08; const ENTRANCE_OFFSET = "translateY(12px)"; export const Route = createFileRoute("/sign")({ - beforeLoad: async ({ context }) => { + /* + * `?flow_id=` is the handle a brokered authorization arrived with, sent here when its session had + * lapsed. It is a value to carry back, never a place to go: see `resumeUrlFor`. + * + * The key is omitted rather than set to undefined, for the reason the connected-accounts screen + * gives for `?connected=`: present-but-undefined makes `search` a required prop on every Link to + * this route, which is a lot of ripple for a parameter only the verifier sets. + */ + validateSearch: (search: Record): { flow_id?: string } => ({ + ...(typeof search.flow_id === "string" ? { flow_id: search.flow_id } : {}), + }), + beforeLoad: async ({ context, search }) => { const user = await context.queryClient.ensureQueryData( currentUserQueryOptions(), ); if (user) { - throw redirect({ to: "/" }); + /* + * Signed in already, so the handle has nowhere to be lost between here and the verifier. `href` + * rather than `to` because the verifier is an API path, not a router route. + */ + const verifier = verifierPathFor(search.flow_id); + throw verifier ? redirect({ href: verifier }) : redirect({ to: "/" }); } // Loaded here so the screen paints with its buttons rather than painting empty and then // growing them, which reads as "no providers" for exactly as long as the request takes. @@ -41,6 +58,7 @@ export const Route = createFileRoute("/sign")({ }); function SignScreen() { + const { flow_id: flowId } = Route.useSearch(); // Which provider is being opened, rather than whether one is: with three buttons, a single // boolean would put "Opening…" on all of them. const [opening, setOpening] = useState(null); @@ -61,7 +79,11 @@ function SignScreen() { setOpening("sso"); try { - await signInWithEmailDomain(email); + await signInWithEmailDomain( + email, + undefined, + resumeUrlFor(window.location.origin, flowId), + ); } catch (caughtError) { setError( caughtError instanceof Error @@ -77,7 +99,11 @@ function SignScreen() { setOpening(provider); try { - await signInWith(provider); + await signInWith( + provider, + undefined, + resumeUrlFor(window.location.origin, flowId), + ); } catch (caughtError) { setError( caughtError instanceof Error diff --git a/app/tests/sign-resume.test.tsx b/app/tests/sign-resume.test.tsx new file mode 100644 index 000000000..da311f8ba --- /dev/null +++ b/app/tests/sign-resume.test.tsx @@ -0,0 +1,140 @@ +import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { authKeys } from "@/lib/auth/queries"; +import { resumeUrlFor } from "@/lib/auth/resume"; + +/* + * The Better Auth client is replaced at the module boundary: its fetch is captured when it is built, + * so a test cannot intercept the request. What this pins is what the screen hands the starter. + */ +type SignInSpy = ( + provider: string, + start: unknown, + callbackURL?: string, +) => Promise; +const realAuthClient = { ...(await import("@/lib/auth/client")) }; +let signInSpy: SignInSpy = async () => {}; +mock.module("@/lib/auth/client", () => ({ + providerName: () => "Google", + signInWith: (...args: Parameters) => signInSpy(...args), + signInWithEmailDomain: async () => {}, +})); +const { Route } = await import("@/routes/sign"); + +beforeAll(() => { + GlobalRegistrator.register(); + // happy-dom starts at about:blank, whose origin is "null": nothing to build a callback URL on. + ( + window as unknown as { happyDOM: { setURL(url: string): void } } + ).happyDOM.setURL("http://localhost:3010/"); +}); +afterEach(() => { + cleanup(); +}); +afterAll(() => { + // Module mocks are process-wide in bun: hand the real client back to whatever runs next. + mock.module("@/lib/auth/client", () => realAuthClient); + GlobalRegistrator.unregister(); +}); + +/* + * THE HANDLE IS A VALUE, NEVER A DESTINATION: it is encoded into a path this code builds on this + * origin, so what arrives in a URL cannot decide where the browser goes. + */ +test("resumeUrlFor puts an encoded handle on the verifier, and is the origin without one", () => { + expect(resumeUrlFor("https://app.example", "a b&c/../d")).toBe( + "https://app.example/api/plugins/arcade/verify?flow_id=a%20b%26c%2F..%2Fd", + ); + expect(resumeUrlFor("https://app.example", undefined)).toBe( + "https://app.example", + ); + expect(resumeUrlFor("https://app.example", "")).toBe("https://app.example"); + // A hostile handle stays a value on this origin. + const hostile = resumeUrlFor("https://app.example", "https://evil.test/"); + expect(new URL(hostile).origin).toBe("https://app.example"); + // Length-capped. + expect( + resumeUrlFor("https://app.example", "x".repeat(5000)).length, + ).toBeLessThan(600); +}); + +test("validateSearch keeps a string flow_id, omits the key otherwise", () => { + const validate = Route.options.validateSearch as (s: unknown) => object; + expect(validate({ flow_id: "abc" })).toEqual({ flow_id: "abc" }); + expect(validate({ flow_id: 42 })).toEqual({}); + expect("flow_id" in validate({ flow_id: ["a"] })).toBe(false); + expect("flow_id" in validate({})).toBe(false); +}); + +function signedInContext() { + const queryClient = new QueryClient(); + queryClient.setQueryData(authKeys.currentUser(), { id: "u1" }); + return { queryClient }; +} + +async function redirectFrom(search: { flow_id?: string }) { + const beforeLoad = Route.options.beforeLoad as (a: unknown) => Promise; + try { + await beforeLoad({ context: signedInContext(), search }); + } catch (thrown) { + return thrown as { options: { to?: string; href?: string } }; + } + throw new Error("expected a redirect"); +} + +test("a signed-in arrival with a handle goes to the verifier, without one to /", async () => { + const withHandle = await redirectFrom({ flow_id: "f 1" }); + expect(withHandle.options.href).toBe( + "/api/plugins/arcade/verify?flow_id=f%201", + ); + const without = await redirectFrom({}); + expect(without.options.to).toBe("/"); + expect(without.options.href).toBeUndefined(); +}); + +test("the sign-in starter gets the resume URL as its callbackURL", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(authKeys.providers(), { + providers: ["google"], + sso: false, + ssoRequired: false, + }); + const asked: string[] = []; + signInSpy = async (_provider, _start, callbackURL) => { + asked.push(callbackURL ?? ""); + }; + + queryClient.setQueryData(authKeys.currentUser(), null); + const root = createRootRoute(); + const sign = Route.update({ + id: "/sign", + path: "/sign", + getParentRoute: () => root, + } as never); + // The route tree is assembled by hand, so its types cannot describe it. + const router = createRouter({ + routeTree: root.addChildren([sign]), + context: { queryClient }, + history: createMemoryHistory({ initialEntries: ["/sign?flow_id=h1"] }), + } as never) as never; + const view = render( + + + , + ); + const user = userEvent.setup({ document: view.container.ownerDocument }); + await user.click(await view.findByText(/Continue with Google/)); + await waitFor(() => expect(asked).toHaveLength(1)); + expect(asked[0]).toBe( + `${window.location.origin}/api/plugins/arcade/verify?flow_id=h1`, + ); +}); From 628eed825214ef08bd56dfe5d2759c9d5835fe1f Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:03:54 +0200 Subject: [PATCH 080/232] fix(arcade): the unconfigured-broker sentence names the active provider, and a person is told no vendor BrokerUnconfiguredError defaulted its provider to Composio, so any call site passing nothing put COMPOSIO_API_KEY on an Arcade deployment. The provider and the reader are now required: an operator is told the active provider's variable (from BROKERED_PROFILE), a person is told no vendor and no setting they cannot reach. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/broker.ts | 21 ++++++-- server/src/plugins/routes.ts | 24 +++++++-- server/src/plugins/store.ts | 11 ++-- .../tests/broker-unconfigured-voice.test.ts | 53 +++++++++++++++++++ server/tests/composio-adapter.test.ts | 8 ++- server/tests/composio-broker-seam.test.ts | 2 +- server/tests/plugin-routes.test.ts | 20 ++++--- 7 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 server/tests/broker-unconfigured-voice.test.ts diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 2833d1afd..4d793e8a2 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -13,7 +13,7 @@ * the import graph of every test that touches enablement. `AccountRef` and `BrokerProvider`, below, are the only imports * that cross this file's boundary, and both are types this deployment names, never the vendor's. */ -import type { BrokerProvider } from "./access"; +import { BROKERED_PROFILE, type BrokerProvider } from "./access"; import type { AccountRef } from "./shared-accounts"; /** @@ -832,11 +832,22 @@ export class BrokerRefusalError extends Error { * message where no call was made at all. */ export class BrokerUnconfiguredError extends BrokerRefusalError { - constructor(provider = "Composio") { + /** + * BOTH ARGUMENTS ARE REQUIRED, AND THAT IS THE FIX. This used to default to Composio, so a call + * site that passed nothing put `COMPOSIO_API_KEY` on an Arcade deployment. An omission is a + * compile error now: every site must say which provider it means and who is reading. + * + * `reader` IS WHO THE SENTENCE IS FOR. An `operator` can set an environment variable, so the + * sentence names the ACTIVE provider's variable (from {@link BROKERED_PROFILE}). A `person` can + * set nothing, and a vendor they do not use and a key they cannot reach is the vendor + * vocabulary spec D11 keeps off their screen, so theirs names neither. + */ + constructor(provider: BrokerProvider, reader: "operator" | "person") { + const { vendor, envVar } = BROKERED_PROFILE[provider]; super( - provider === "Composio" - ? "Composio is not configured for this deployment, so nothing was asked. Set COMPOSIO_API_KEY to make the brokered apps available; until it is set there is nothing to connect, nothing to grant and no Composio tool for a Bot to call, and the admin Plugins page shows one row under More apps that goes nowhere." - : `${provider} is not configured for this deployment, so nothing was asked.`, + reader === "person" + ? "Connected apps are not set up for this deployment, so nothing was asked. An administrator can set them up." + : `${vendor} is not configured for this deployment, so nothing was asked. Set ${envVar} to make the brokered apps available; until it is set there is nothing to connect, nothing to grant and no ${vendor} tool for a Bot to call, and the admin Plugins page shows one row under More apps that goes nowhere.`, ); this.name = "BrokerUnconfiguredError"; } diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 5cac5295f..aeb22d444 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -923,7 +923,10 @@ export function createPluginRoutes( if (!composio) { return context.json( - { error: new BrokerUnconfiguredError().message }, + { + error: new BrokerUnconfiguredError(activeProvider, "operator") + .message, + }, 503, ); } @@ -1013,7 +1016,10 @@ export function createPluginRoutes( if (!composio) { return context.json( - { error: new BrokerUnconfiguredError().message }, + { + error: new BrokerUnconfiguredError(activeProvider, "operator") + .message, + }, 503, ); } @@ -1371,7 +1377,10 @@ export function createPluginRoutes( */ if (!composio) { return context.json( - { error: new BrokerUnconfiguredError().message }, + { + error: new BrokerUnconfiguredError(activeProvider, "person") + .message, + }, 503, ); } @@ -2071,7 +2080,10 @@ export function createPluginRoutes( } if (!composio) { return { - refusal: { error: new BrokerUnconfiguredError().message, status: 503 }, + refusal: { + error: new BrokerUnconfiguredError(activeProvider, "person").message, + status: 503, + }, }; } const resolved = await store.accountRefFor(serverId, context.var.actor.id); @@ -2355,7 +2367,9 @@ export function createPluginRoutes( if (!composio) { return context.json( - { error: new BrokerUnconfiguredError().message }, + { + error: new BrokerUnconfiguredError(activeProvider, "person").message, + }, 503, ); } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 9605aae0c..459d4899c 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3418,7 +3418,8 @@ export function createPluginStore(options: PluginStoreOptions) { }): Promise { // Before anything at all. A deployment with no key has no catalogue for this app to have been // chosen from, so there is nothing here to half-do and nothing to say but the setting. - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) + throw new BrokerUnconfiguredError(brokerProvider, "operator"); const profile = BROKERED_PROFILE[brokerProvider]; const url = `${profile.scheme}${input.slug}`; @@ -6773,7 +6774,7 @@ export function createPluginStore(options: PluginStoreOptions) { }): Promise<{ connected: boolean }> { // Before anything, and for the reason `addBrokeredApp` says it first too: a deployment with // no key has no broker to have connected anybody at, so there is nothing here to ask. - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); const connected = await broker.isConnected({ account: input.account, @@ -7087,7 +7088,7 @@ export function createPluginStore(options: PluginStoreOptions) { }): Promise<{ connected: true; verified: boolean; probe: string | null }> { // First, and for `confirmBrokeredConnection`'s reason: a deployment with no key has nobody to // connect anybody at, and the refusal must happen before the values are touched at all. - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); // Keyed on the url and on the one row that answers for it — see `brokeredAppScheme`. It is // the same reasoning the connection gate in `connectionTokenFor` is keyed on, and for the @@ -7489,7 +7490,7 @@ export function createPluginStore(options: PluginStoreOptions) { verifiedAt: string | null; probe: string | null; }> { - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); // Keyed on the url and on the one row that answers for it — see `brokeredAppKind`. It is the // read `connectBrokeredWithFields`, `confirmBrokeredConnection` and `disconnectBrokered` all @@ -7796,7 +7797,7 @@ export function createPluginStore(options: PluginStoreOptions) { */ reason: "self" | "person_removed" | "admin" | "mode_switched"; }): Promise<{ vendorRevocationRequested: boolean }> { - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); /* * Keyed on the url and on the one row that answers for it — see {@link brokeredAppKind}. diff --git a/server/tests/broker-unconfigured-voice.test.ts b/server/tests/broker-unconfigured-voice.test.ts new file mode 100644 index 000000000..269e79dae --- /dev/null +++ b/server/tests/broker-unconfigured-voice.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { BrokerUnconfiguredError } from "../src/plugins/broker"; + +/** + * Which provider an "unconfigured broker" sentence names, and who it is written for. + * + * The constructor used to DEFAULT to Composio, so a call site that passed nothing put + * `COMPOSIO_API_KEY` on an Arcade deployment's screen. The parameter is required now and these pin + * both halves: the operator's sentence names the ACTIVE provider's variable, and a person's names + * no vendor and no variable at all (spec D11) because they can set neither. + * + * NO-ARGUMENT PIN IS A RUNTIME ASSERTION, NOT `@ts-expect-error`: `server/tests` is outside `tsc`, + * so an expect-error written here would assert nothing. `Function.length` counts the parameters + * before the first default, so it falls the moment a default is reintroduced; the second assertion + * is the behaviour that makes an omission loud rather than a Composio sentence. + */ +const COMPOSIO_OPERATOR = + "Composio is not configured for this deployment, so nothing was asked. Set COMPOSIO_API_KEY to make the brokered apps available; until it is set there is nothing to connect, nothing to grant and no Composio tool for a Bot to call, and the admin Plugins page shows one row under More apps that goes nowhere."; + +describe("BrokerUnconfiguredError", () => { + test("a Composio operator reads the exact existing sentence", () => { + expect(new BrokerUnconfiguredError("composio", "operator").message).toBe( + COMPOSIO_OPERATOR, + ); + }); + + test("an Arcade operator is told Arcade's variable and never Composio's", () => { + const message = new BrokerUnconfiguredError("arcade", "operator").message; + expect(message).toContain("Arcade"); + expect(message).toContain("ARCADE_API_KEY"); + expect(message).not.toMatch(/composio/i); + expect(message).not.toContain("COMPOSIO_API_KEY"); + }); + + test("a person is told no vendor and no variable, on either provider", () => { + for (const provider of ["composio", "arcade"] as const) { + const message = new BrokerUnconfiguredError(provider, "person").message; + expect(message).not.toMatch(/composio|arcade/i); + expect(message).not.toMatch(/API_KEY/); + } + expect(new BrokerUnconfiguredError("composio", "person").message).toBe( + new BrokerUnconfiguredError("arcade", "person").message, + ); + }); + + test("the constructor cannot be called with nothing", () => { + expect(BrokerUnconfiguredError.length).toBe(2); + expect( + // biome-ignore lint/suspicious/noExplicitAny: the omission is the point + () => new (BrokerUnconfiguredError as any)(), + ).toThrow(); + }); +}); diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 761c0aa43..1b20d1711 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -8500,7 +8500,11 @@ describe("accounts named by holder", () => { }); test("an unconfigured broker names the provider it is missing", () => { - expect(new BrokerUnconfiguredError("Acme").message).toMatch(/Acme/); - expect(new BrokerUnconfiguredError().message).toMatch(/COMPOSIO_API_KEY/); + expect(new BrokerUnconfiguredError("arcade", "operator").message).toMatch( + /Arcade/, + ); + expect(new BrokerUnconfiguredError("composio", "operator").message).toMatch( + /COMPOSIO_API_KEY/, + ); }); }); diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index 6a8577b89..a9ed288c1 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -24,7 +24,7 @@ import { createComposioClient } from "../src/plugins/composio-adapter"; describe("brokerSentence", () => { test("reports the unconfigured deployment in the error's own words", () => { - const error = new BrokerUnconfiguredError(); + const error = new BrokerUnconfiguredError("composio", "operator"); expect(error.message).toContain("COMPOSIO_API_KEY"); expect(brokerSentence(error)).toBe(error.message); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index de94478b6..418be51c6 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -2164,7 +2164,9 @@ describe("connecting a brokered app", () => { const response = await connect({}); expect(response.status).toBe(503); - expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + expect((await response.json()).error).not.toMatch( + /composio|COMPOSIO_API_KEY/i, + ); }); test("the address Composio sends somebody back to is built here, never taken from the request", async () => { @@ -3050,10 +3052,14 @@ describe("confirming and ending a brokered connection", () => { expect(confirmResponse.status).toBe(503); expect(disconnectResponse.status).toBe(503); - expect((await confirmResponse.json()).error).toContain("COMPOSIO_API_KEY"); - expect((await disconnectResponse.json()).error).toContain( - "COMPOSIO_API_KEY", - ); + // A PERSON'S route: no vendor, no variable they cannot set (spec D11). + for (const refusal of [ + (await confirmResponse.json()).error as string, + (await disconnectResponse.json()).error as string, + ]) { + expect(refusal).not.toContain("COMPOSIO_API_KEY"); + expect(refusal).not.toMatch(/composio/i); + } expect(confirmed).toEqual([]); expect(disconnected).toEqual([]); }); @@ -3201,7 +3207,9 @@ describe("re-checking a brokered connection", () => { const response = await recheck(); expect(response.status).toBe(503); - expect((await response.json()).error).toContain("COMPOSIO_API_KEY"); + expect((await response.json()).error).not.toMatch( + /composio|COMPOSIO_API_KEY/i, + ); expect(rechecked).toEqual([]); }); From c459cc49131a9a5238d58c975c561597b075703f Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:04:36 +0200 Subject: [PATCH 081/232] style(arcade): wrap the widened outcome type argument as biome wants it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `b4bb8fe2` added "withdrawn" to `pending_authorizations.outcome`'s `$type<…>`, which pushed the type argument past the line width. `format:check` has been red on the branch since, which is a push blocker and nothing to do with the change that caused it. Formatting only; no type or behaviour change. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/db/schema/plugins.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index bd502b282..3eac3a74d 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -473,8 +473,9 @@ export const pendingAuthorizations = pgTable("pending_authorizations", { authId: text("auth_id"), createdAt: createdAt(), completedAt: timestamp("completed_at", { withTimezone: true }), - outcome: text("outcome") - .$type<"completed" | "failed" | "unreconciled" | "withdrawn">(), + outcome: text("outcome").$type< + "completed" | "failed" | "unreconciled" | "withdrawn" + >(), }); /** From 1c726658914a58d0e2b77fd34f6a1861e1f8f577 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:04:36 +0200 Subject: [PATCH 082/232] test(arcade): await the render the directory test releases, so the file exits 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8.2's acceptance test passed 3/3 and still exited 1 when run on its own: 3 pass / 0 fail / 1 error ← "# Unhandled error between tests" ReferenceError: window is not defined (scheduler → window.event) The third case holds /api/plugins behind a promise, asserts synchronously, calls release() and returns without awaiting. The released fetch resolves, React Query writes the cache, React schedules a render -- and by the time the scheduler callback runs the test has ended, afterEach(cleanup) has run and afterAll's GlobalRegistrator.unregister() has deleted `window`. The callback then dereferences window.event and throws into the runner. Not an inherited happy-dom/React 19 defect: composio-route-plugins-error, agent-roster-error and brokered-account-row are all built the same way and all exit 0 in isolation. Worth fixing rather than noting, because every brief in this manifest ends with "run `bun test `" -- so the next executor here would meet exit 1 and either lose time or learn to discount bun's exit code, which is the habit the standing constraint about pipes exists to prevent. It is also the one file that proves wave 8's central deliverable. Awaiting the redirect drains React's queue inside the test. Verified: the file alone now exits 0 with 0 unhandled-error blocks. Co-Authored-By: Claude Opus 5 (1M context) --- app/tests/arcade-directory-screen.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/tests/arcade-directory-screen.test.tsx b/app/tests/arcade-directory-screen.test.tsx index c0ca6164f..95f4a2006 100644 --- a/app/tests/arcade-directory-screen.test.tsx +++ b/app/tests/arcade-directory-screen.test.tsx @@ -194,4 +194,11 @@ test("the Arcade directory draws nothing until the page names a provider", async expect(view.queryByText("the plugins index")).toBeNull(); expect(view.queryByRole("alert")).toBeNull(); release(); + // Drain what the release started. The deferred stub answers `{ apps: [] }`, which carries no + // `provider`, so `pluginsProvider` defaults to "composio" and this route redirects to the decoy. + // Awaiting that here is what keeps React's scheduled work INSIDE the test: released and then + // abandoned, the render lands after `cleanup` and after `GlobalRegistrator.unregister()` has + // removed `window`, and `scheduler`'s `window.event` throws into the runner — three passes and + // an exit code of 1, on the one file that proves wave 8's deliverable. + await view.findByText("the plugins index"); }); From 630dce920d6acb37181df78d8f6b454e9cd2babe Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:04:54 +0200 Subject: [PATCH 083/232] test(arcade): pin that the account-mode switch ends sibling apps, and mark the removal gap Investigation: the Personal/Shared switch already reaches the sibling cascade, because account-mode.ts calls store.disconnectBrokered per account (reason mode_switched). Pinned here, not changed. For a Shared app the sibling set is empty by construction (each Shared app has its own minted identity); a test pins that a second Shared app on the same provider is left alone. App removal does NOT get the cascade: store.removeServer calls broker.revoke directly and deletes only app = toolkit, so a sibling the vendor just ended keeps its row and gets no audit event. Closing it needs store.ts, outside this task's fence, so that test is test.failing and will flip red when the gap is closed. Scoping: audit_events and brokered_connections outlive tests, so every test mints its own run() suffix on each app and vendor identity, and reads rows and trail only by those names. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/arcade-sibling-collateral.test.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 server/tests/arcade-sibling-collateral.test.ts diff --git a/server/tests/arcade-sibling-collateral.test.ts b/server/tests/arcade-sibling-collateral.test.ts new file mode 100644 index 000000000..b0fb85233 --- /dev/null +++ b/server/tests/arcade-sibling-collateral.test.ts @@ -0,0 +1,218 @@ +// server/tests/arcade-sibling-collateral.test.ts +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { auditEvents, brokeredConnections, mcpServers } from "../src/db/schema"; +import { createAccountModeSwitch } from "../src/plugins/account-mode"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createSharedUseStore } from "../src/plugins/shared-use-store"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/* + * SCOPING. `audit_events` is append-only and `brokered_connections` is long-lived, so every + * assertion here is keyed on names THIS TEST ALONE writes: each test mints its own `run()` — a + * fresh suffix on every app and every vendor identity it touches — and reads rows and the trail + * only by those. A "did not happen" assertion is therefore one only this test could have falsified. + */ +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); + +const providerOf = (toolkit: string) => + toolkit.startsWith("Slack-") ? "slack" : "google"; + +const broker: ConnectedAppBroker = { + grantIsLocal: false, + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + authProviderOf: async (toolkit) => providerOf(toolkit), + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: "ar_1", + }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme", +}; + +const store = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, + deploymentId: "acme", + sharedUse: async () => ({ allowed: true }), +}); +const modes = createAccountModeSwitch({ + store, + sharedUse: createSharedUseStore(database, "arcade"), + deploymentId: "acme", + audit: createAuditStore(database), + teamRules: undefined, +}); + +const apps: string[] = []; +let n = 0; +/** This test's own names: nothing another test writes can share one. */ +function run() { + n += 1; + const tag = `${suite}${n}`; + const names = { + gmail: `Gmail-${tag}`, + calendar: `GoogleCalendar-${tag}`, + slack: `Slack-${tag}`, + person: `person-${tag}`, + }; + apps.push(names.gmail, names.calendar, names.slack); + return names; +} +async function server(app: string, mode: "personal" | "shared") { + await database.insert(mcpServers).values({ + id: app, + title: app, + vendor: "Arcade", + url: `arcade://${app}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: mode, + }); +} +async function hold( + app: string, + vendorUserId: string, + holder: "person" | "deployment" = "person", +) { + await database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder, + userId: holder === "person" ? vendorUserId : null, + vendorUserId, + }); +} +const rowsFor = async (names: string[]) => + ( + await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where(inArray(brokeredConnections.app, names)) + .orderBy(asc(brokeredConnections.app)) + ).map((row) => row.app); +const trailFor = (names: string[]) => + database + .select({ + app: auditEvents.targetId, + payload: sql>`payload`, + }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_disconnected"), + inArray(auditEvents.targetId, names), + ), + ) + .orderBy(asc(auditEvents.targetId)); + +afterEach(async () => { + if (apps.length) { + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, apps)); + await database.delete(mcpServers).where(inArray(mcpServers.id, apps)); + } + apps.length = 0; +}); + +describe("the Personal to Shared switch", () => { + test("ends a sibling app's connection too, and removes and audits its row", async () => { + const t = run(); + await server(t.gmail, "personal"); + await hold(t.gmail, t.person); + await hold(t.calendar, t.person); // same identity, same provider: ended with Gmail + await hold(t.slack, t.person); // another provider: not touched + + const result = await modes.switchMode({ + serverId: t.gmail, + mode: "shared", + by: "admin", + confirm: true, + }); + + expect(result).toEqual({ changed: true, revoked: 1 }); + expect(await rowsFor([t.gmail, t.calendar, t.slack])).toEqual([t.slack]); + const trail = await trailFor([t.gmail, t.calendar, t.slack]); + expect(trail.map((event) => event.app)).toEqual( + [t.calendar, t.gmail].sort(), + ); + const sibling = trail.find((event) => event.app === t.calendar); + expect(sibling?.payload).toMatchObject({ + reason: "mode_switched", + endedWith: t.gmail, + owner: t.person, + }); + }); + + test("a Shared app's switch back leaves another Shared app on the same provider alone", async () => { + const t = run(); + await server(t.gmail, "shared"); + // Two Shared apps, one provider, but each its OWN minted identity: no shared connection. + await hold(t.gmail, `deployment-${t.gmail}`, "deployment"); + await hold(t.calendar, `deployment-${t.calendar}`, "deployment"); + + const result = await modes.switchMode({ + serverId: t.gmail, + mode: "personal", + by: "admin", + confirm: true, + }); + + expect(result).toEqual({ changed: true, revoked: 1 }); + expect(await rowsFor([t.gmail, t.calendar])).toEqual([t.calendar]); + expect((await trailFor([t.gmail, t.calendar])).map((e) => e.app)).toEqual([ + t.gmail, + ]); + }); +}); + +describe("removing an app", () => { + /* + * KNOWN GAP, NOT A PASSING PIN. `removeServer` (store.ts) revokes each connected account with a + * direct `broker.revoke` and deletes only `app = toolkit`, so a sibling the vendor just ended keeps + * its local row and gets no audit row. It does not go through `disconnectBrokered`'s cascade, and + * store.ts is outside this micro-task's fence. `test.failing` makes this flip to a failure the + * moment the gap is closed, at which point it must become a plain `test`. + */ + test.failing("ends a sibling app's connection too, and removes and audits its row", async () => { + const t = run(); + await server(t.gmail, "personal"); + await hold(t.gmail, t.person); + await hold(t.calendar, t.person); + await hold(t.slack, t.person); + + await store.removeServer(t.gmail, "admin"); + + expect(await rowsFor([t.gmail, t.calendar, t.slack])).toEqual([t.slack]); + const trail = await trailFor([t.gmail, t.calendar, t.slack]); + expect(trail.map((event) => event.app)).toEqual( + [t.calendar, t.gmail].sort(), + ); + }); +}); From bd117d33689eb1d21839628150db110c63fee68f Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:07:37 +0200 Subject: [PATCH 084/232] fix(arcade): removing an app ends and audits the siblings the vendor ended with it removeServer revoked each connected account directly and deleted only app = toolkit, so on Arcade removing Gmail ended GoogleCalendar at the vendor while Calendar's local row survived with no audit event. Shape chosen: read resolveSiblings per account before revoking, then delete and audit the siblings with endedWith. Routing through disconnectBrokered was rejected: its reason is a closed set that cannot say mcp_server_removed without touching it (fenced), and doing so would rewrite the Composio trail, which must not change. No pair-matching is written here; the sibling set is resolveSiblings' answer, and the loop is one delete and one audit row. Preserved: revoke at the vendor before any local delete; a throwing revoke leaves every row standing; revoke's boolean is read. A sibling is deleted and audited only for an account whose revoke answered true, so a false (nothing found, or auth provider unresolved) never claims a sibling ended. Sibling audit rows carry endedWith and the unansweredSiblings integer. Composio's sibling set is empty, so its removal is unchanged. Tests: the app-removal pin is a plain test (gap comment removed). New cases: revoke false leaves the sibling row and trail empty, and a throwing revoke leaves all rows standing. Each case mints its own suite+counter app names and reads rows and audit via inArray on those names, so the append-only table cannot supply a false zero. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 51 ++++++++++++ .../tests/arcade-sibling-collateral.test.ts | 80 +++++++++++++++++-- 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 459d4899c..9dffa1b89 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -4010,7 +4010,18 @@ export function createPluginStore(options: PluginStoreOptions) { * either way — but nothing was asked of Composio and the row must not claim otherwise. */ const vendorRevocationRequested: boolean[] = []; + /* + * THE APPS EACH ACCOUNT'S REVOCATION ENDS BESIDES THIS ONE, read BEFORE anything is revoked + * and through {@link resolveSiblings}, the one place that answers it. Where a connection is + * keyed `(identity, auth provider)` — Arcade — withdrawing it ends every app sharing it, so a + * local row left for one of them claims an account the vendor no longer holds. On Composio the + * set is empty by construction and nothing below differs from what this method always did. + */ + const endedBesides: { siblings: string[]; unanswered: number }[] = []; for (const connection of connected) { + endedBesides.push( + await resolveSiblings({ toolkit, account: accountOf(connection) }), + ); vendorRevocationRequested.push( broker ? await broker.revoke({ account: accountOf(connection), toolkit }) @@ -4080,6 +4091,46 @@ export function createPluginStore(options: PluginStoreOptions) { }); } + /* + * THE SIBLINGS THE VENDOR ENDED WITH THIS APP, each filed as `disconnectBrokered` files one: + * under its own app, naming the app whose removal ended it, with the count of apps that could + * not be asked about. Only for an account whose revoke ANSWERED TRUE. `false` means nothing + * was found to withdraw or the auth provider could not be resolved, and the vendor was not + * shown to have ended anything, so a sibling's row is left standing rather than claimed + * gone. It sits after the revokes above for the same reason the asked app's rows do: an + * exception from any of them left every row standing. + */ + for (const [index, connection] of connected.entries()) { + const { siblings, unanswered } = endedBesides[index] ?? { + siblings: [], + unanswered: 0, + }; + if (!vendorRevocationRequested[index]) continue; + const account = accountOf(connection); + for (const app of siblings) { + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(app, account)) + .returning({ app: brokeredConnections.app }); + if (!deleted) continue; + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: app, + payload: { + actor: by, + server: app, + owner: + account.holder === "person" ? account.userId : "deployment", + reason: "mcp_server_removed", + vendorRevocationRequested: true, + unansweredSiblings: unanswered, + endedWith: toolkit, + }, + }); + } + } + // Last of all, for the reason above, and skipped entirely on a deployment with no // broker to have made one. if (broker) await broker.deleteAuthConfig(toolkit); diff --git a/server/tests/arcade-sibling-collateral.test.ts b/server/tests/arcade-sibling-collateral.test.ts index b0fb85233..be7989d44 100644 --- a/server/tests/arcade-sibling-collateral.test.ts +++ b/server/tests/arcade-sibling-collateral.test.ts @@ -193,14 +193,7 @@ describe("the Personal to Shared switch", () => { }); describe("removing an app", () => { - /* - * KNOWN GAP, NOT A PASSING PIN. `removeServer` (store.ts) revokes each connected account with a - * direct `broker.revoke` and deletes only `app = toolkit`, so a sibling the vendor just ended keeps - * its local row and gets no audit row. It does not go through `disconnectBrokered`'s cascade, and - * store.ts is outside this micro-task's fence. `test.failing` makes this flip to a failure the - * moment the gap is closed, at which point it must become a plain `test`. - */ - test.failing("ends a sibling app's connection too, and removes and audits its row", async () => { + test("ends a sibling app's connection too, and removes and audits its row", async () => { const t = run(); await server(t.gmail, "personal"); await hold(t.gmail, t.person); @@ -214,5 +207,76 @@ describe("removing an app", () => { expect(trail.map((event) => event.app)).toEqual( [t.calendar, t.gmail].sort(), ); + const sibling = trail.find((event) => event.app === t.calendar); + expect(sibling?.payload).toMatchObject({ + reason: "mcp_server_removed", + endedWith: t.gmail, + owner: t.person, + unansweredSiblings: 0, + }); + }); + + test("a revoke that answers false does not delete or audit a sibling as ended", async () => { + const t = run(); + await server(t.gmail, "personal"); + await hold(t.gmail, t.person); + await hold(t.calendar, t.person); + const unresolved: ConnectedAppBroker = { + ...broker, + revoke: async () => false, + }; + const falseStore = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker: unresolved, + deploymentId: "acme", + sharedUse: async () => ({ allowed: true }), + }); + + await falseStore.removeServer(t.gmail, "admin"); + + // The vendor was not shown to have ended Calendar: its row stands and nothing claims it ended. + expect(await rowsFor([t.gmail, t.calendar])).toEqual([t.calendar]); + const trail = await trailFor([t.gmail, t.calendar]); + expect(trail.map((event) => event.app)).toEqual([t.gmail]); + expect(trail[0]?.payload).toMatchObject({ + vendorRevocationRequested: false, + }); + }); + + test("a partial vendor failure throws and leaves every row standing", async () => { + const t = run(); + await server(t.gmail, "personal"); + await hold(t.gmail, t.person); + await hold(t.calendar, t.person); + const failing = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker: { + ...broker, + revoke: async () => { + throw new Error("vendor down"); + }, + }, + deploymentId: "acme", + sharedUse: async () => ({ allowed: true }), + }); + + await expect(failing.removeServer(t.gmail, "admin")).rejects.toThrow( + "vendor down", + ); + + expect(await rowsFor([t.gmail, t.calendar])).toEqual( + [t.calendar, t.gmail].sort(), + ); + expect(await trailFor([t.gmail, t.calendar])).toEqual([]); }); }); From edeb3173f9372a6276f6e68e860ca49173e6b7a5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:27:10 +0200 Subject: [PATCH 085/232] feat(arcade): GET /servers/:id/grants answers what an account holds, per action Passes GrantState[] through as the broker answered it, so needs-operator (a deployment secret no consent moves) and unknown (never a pessimistic needs-consent) stay distinct from needs-consent. A Personal app asks as the viewer, a Shared app as its shared_vendor_user_id; brokeredAccountFor gains a readOnly option that lifts only the admin requirement, so a non-admin reads a Shared app's status while the mint route still refuses with 403. No presence test on the broker: both providers answer grantsFor. Scoping: every case mints its own suite+counter suffix on the toolkit, app id, viewer and shared identity; captured upstream calls are filtered to that toolkit and the "nothing minted" reads use inArray on that case's own names. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 70 +++++ server/tests/arcade-grants-route.test.ts | 314 +++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 server/tests/arcade-grants-route.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index aeb22d444..1ed43c3a8 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -17,6 +17,7 @@ import { brokerSentence, type ComposioBroker, type Decides, + type GrantState, isFieldScheme, type SchemeKind, schemeKind, @@ -2054,6 +2055,13 @@ export function createPluginRoutes( const brokeredAccountFor = async ( context: { var: AppVariables }, serverId: string, + /** + * `readOnly` is true for a question about the account's STATE, which anybody working with the + * app benefits from, and false for every act done in it. It lifts exactly one refusal — the + * administrator requirement on a Shared app's account — and nothing else: the account + * resolved is the same one either way, so a reader is told about the identity calls use. + */ + options: { readOnly?: boolean } = {}, ): Promise< | { toolkit: string; @@ -2091,6 +2099,7 @@ export function createPluginRoutes( return { refusal: { error: resolved.refusal, status: 400 } }; } if ( + !options.readOnly && resolved.ref.holder === "deployment" && context.var.actor.role !== "admin" ) { @@ -2104,6 +2113,67 @@ export function createPluginRoutes( return { toolkit, account: resolved.ref, title: resolved.title }; }; + /** + * What the account behind this app holds, action by action: `{ actions: GrantState[] }`. + * + * A BOOLEAN WOULD NOT DO. An action can be blocked by consent, which Authorize moves, or by a + * deployment secret an operator sets once for everybody, which no amount of consenting moves; and + * a listing nobody could read is `unknown` rather than a pessimistic `needs-consent`, because + * telling somebody to authorize what they already authorized sends them round twice. The states + * are the broker's, passed through as it answered them. + * + * WHOSE IDENTITY IS ASKED ABOUT COMES FROM `brokeredAccountFor`: a Personal app's is the + * viewer's, a Shared app's is the app's own `shared_vendor_user_id`. Asking about the viewer for + * a Shared app would report a state no call ever uses. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED: the status is a fact about the team's account that + * anybody working with the app benefits from. Minting is the privileged act, and the authorize + * route still refuses it with 403 through the same resolver. + * + * NO PRESENCE TEST ON THE BROKER AND NO REFUSAL PATH: every broker answers `grantsFor`, a + * degenerate answer being a real one, so this works on both deployments. A throw from the broker + * is answered as `unknown` carrying our own sentence, never the vendor's words, which could + * carry a credential. + */ + routes.get("/servers/:id/grants", requireUser, async (context) => { + const resolved = await brokeredAccountFor( + context, + context.req.param("id"), + { readOnly: true }, + ); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + const { toolkit, account } = resolved; + // `composio` is non-null here: the resolver refuses 503 before it returns an account. + const broker = composio?.broker; + if (!broker) { + return context.json( + { + error: new BrokerUnconfiguredError(activeProvider, "person").message, + }, + 503, + ); + } + let actions: GrantState[]; + try { + actions = await broker.grantsFor({ toolkit, account }); + } catch { + actions = [ + { + action: toolkit, + state: "unknown", + reason: + "This deployment could not read what this account holds just now, so nothing is said about it either way. Try again in a moment.", + }, + ]; + } + return context.json({ actions }); + }); + /** * Ask Composio whether this person's account is really attached, and write the answer down. * diff --git a/server/tests/arcade-grants-route.test.ts b/server/tests/arcade-grants-route.test.ts new file mode 100644 index 000000000..a6dd549b2 --- /dev/null +++ b/server/tests/arcade-grants-route.test.ts @@ -0,0 +1,314 @@ +// server/tests/arcade-grants-route.test.ts +/** + * `GET /api/plugins/servers/:id/grants`: what an account holds at an app, action by action. + * + * THE IDENTITY SENT UPSTREAM IS WHAT IS ASSERTED, never what rendered. The stub broker records the + * `account` each `grantsFor` call receives, and cases 1 and 2 read THAT. A route that asked about + * the viewer for a Shared app would render a perfectly plausible answer. + * + * SCOPING. `audit_events` is append-only and nothing here is cleaned between cases, so every case + * mints its own `run()`: a fresh `suite+counter` suffix on the toolkit, the app id, the viewer and + * the shared identity. The captured calls are filtered to THIS case's toolkit, and the "mint + * refused" check reads `audit_events` and `pending_authorizations` through `inArray` on this case's + * own names only, so a minted attempt by any other case could not satisfy or redden it. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { inArray } from "drizzle-orm"; +import { createApp } from "../src/app"; +import { createAuditStore } from "../src/audit"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { + auditEvents, + mcpServers, + pendingAuthorizations, +} from "../src/db/schema"; +import type { ConnectedAppBroker, GrantState } from "../src/plugins/broker"; +import type { AccountRef } from "../src/plugins/shared-accounts"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +let counter = 0; + +type Provider = "arcade" | "composio"; +let calls: { toolkit: string; account: AccountRef }[] = []; +let mints: string[] = []; +let answer: (q: { + toolkit: string; + account: AccountRef; +}) => Promise = async () => []; + +const recordGrants: ConnectedAppBroker["grantsFor"] = async (q) => { + calls.push(q); + return answer(q); +}; + +/** Declares the correlated flow and mints an authorization id: Arcade's shape. */ +const arcadeBroker: ConnectedAppBroker = { + grantIsLocal: false, + authProviderOf: async () => "google", + authorizations: { + status: async () => ({ status: "pending", userId: null, scopes: [] }), + confirmReturn: async () => ({ mismatch: true }), + }, + grantsFor: recordGrants, + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async (request) => { + mints.push(request.toolkit); + return { + redirectUrl: "https://vendor.example/consent?bearer=1", + authorizationId: "ar_1", + }; + }, + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => null, +}; + +/** Declares no flow, mints no authorization id, and still answers `grantsFor`. */ +const composioBroker: ConnectedAppBroker = { + grantIsLocal: false, + authProviderOf: async () => "google", + authorizations: null, + grantsFor: recordGrants, + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async (request) => { + mints.push(request.toolkit); + return { + redirectUrl: "https://vendor.example/consent?bearer=1", + authorizationId: null, + }; + }, + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => null, +}; + +const brokerFor = (provider: Provider) => + provider === "arcade" ? arcadeBroker : composioBroker; + +function build(who: string, role: "admin" | "user", provider: Provider) { + const broker = brokerFor(provider); + const auditStore = createAuditStore(database); + const pluginStore = createPluginStore({ + brokerProvider: provider, + database, + auditStore, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + const args = new Array(createApp.length) as unknown as Parameters< + typeof createApp + >; + args[0] = loadConfig( + testEnvironment({ OPENBOT_APP_URL: "https://app.example" }), + ); + args[1] = { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => ({ + user: { id: who, email: `${who}@example.test`, name: "Person" }, + }), + }, + } as never; + args[2] = { rolesForUser: async () => [role] } as never; + args[12] = auditStore; + args[14] = pluginStore; + args[29] = { broker, provider }; + return createApp(...args); +} + +const ids: string[] = []; +const toolkits: string[] = []; +const viewers: string[] = []; + +/** This case's own names. Nothing another case writes can share one. */ +async function run(mode: "personal" | "shared", provider: Provider = "arcade") { + counter += 1; + const tag = `${suite}${counter}`; + const names = { + id: `grants-${tag}`, + toolkit: `Gmail${tag}`, + viewer: `viewer-${tag}`, + sharedIdentity: `openbot-deployment:acme:${tag}`, + }; + ids.push(names.id); + toolkits.push(names.toolkit); + viewers.push(names.viewer); + await database.insert(mcpServers).values({ + id: names.id, + title: "Gmail", + vendor: provider === "arcade" ? "Arcade" : "Composio", + url: `${provider}://${names.toolkit}`, + provenance: provider, + authScheme: "OAUTH2", + accountMode: mode, + sharedVendorUserId: mode === "shared" ? names.sharedIdentity : null, + }); + calls = []; + mints = []; + answer = async () => []; + return names; +} + +const grants = ( + who: string, + role: "admin" | "user", + id: string, + provider: Provider = "arcade", +) => + build(who, role, provider).request( + `http://openbot.test/api/plugins/servers/${id}/grants`, + ); + +afterAll(async () => { + await database.delete(mcpServers).where(inArray(mcpServers.id, ids)); +}); + +describe("whose identity is asked about", () => { + test("a personal app asks upstream as the viewer", async () => { + const n = await run("personal"); + answer = async () => [ + { action: "Gmail.ListEmails", state: "authorized", scopes: ["a"] }, + ]; + const response = await grants(n.viewer, "user", n.id); + expect(response.status).toBe(200); + const mine = calls.filter((c) => c.toolkit === n.toolkit); + expect(mine.map((c) => c.account)).toEqual([ + { holder: "person", userId: n.viewer, vendorUserId: n.viewer }, + ]); + }); + + test("a shared app asks upstream as the app's identity, never the viewer", async () => { + const n = await run("shared"); + const response = await grants(n.viewer, "admin", n.id); + expect(response.status).toBe(200); + const mine = calls.filter((c) => c.toolkit === n.toolkit); + expect(mine.map((c) => c.account)).toEqual([ + { holder: "deployment", vendorUserId: n.sharedIdentity }, + ]); + expect(JSON.stringify(mine)).not.toContain(n.viewer); + }); + + test("a non-admin reads a shared app's status (200) but is refused the mint (403)", async () => { + const n = await run("shared"); + const read = await grants(n.viewer, "user", n.id); + expect(read.status).toBe(200); + expect( + calls.filter((c) => c.toolkit === n.toolkit).map((c) => c.account), + ).toEqual([{ holder: "deployment", vendorUserId: n.sharedIdentity }]); + + const mint = await build(n.viewer, "user", "arcade").request( + `http://openbot.test/api/plugins/servers/${n.id}/authorize`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "Gmail.ListEmails" }), + }, + ); + expect(mint.status).toBe(403); + // Scoped to this case's own app and viewer: nothing was minted or recorded for them. + expect(mints.filter((t) => t === n.toolkit)).toEqual([]); + const attempts = await database + .select() + .from(pendingAuthorizations) + .where(inArray(pendingAuthorizations.actorId, [n.viewer])); + expect(attempts).toEqual([]); + const trail = await database + .select() + .from(auditEvents) + .where(inArray(auditEvents.targetId, [n.toolkit, n.id])); + expect(trail.filter((e) => e.eventType.includes("authoriz"))).toEqual([]); + }); +}); + +describe("what the states say", () => { + test("blocked only by a deployment secret: needs-operator, naming it", async () => { + const n = await run("personal"); + answer = async () => [ + { + action: "Github.ListRepos", + state: "needs-operator", + missing: ["GITHUB_SERVER_URL"], + }, + ]; + const body = (await (await grants(n.viewer, "user", n.id)).json()) as { + actions: GrantState[]; + }; + expect(body.actions).toEqual([ + { + action: "Github.ListRepos", + state: "needs-operator", + missing: ["GITHUB_SERVER_URL"], + }, + ]); + }); + + test("blocked by consent: needs-consent carrying its scopes", async () => { + const n = await run("personal"); + answer = async () => [ + { + action: "Gmail.SendEmail", + state: "needs-consent", + scopes: ["gmail.send"], + }, + ]; + const body = (await (await grants(n.viewer, "user", n.id)).json()) as { + actions: GrantState[]; + }; + expect(body.actions).toEqual([ + { + action: "Gmail.SendEmail", + state: "needs-consent", + scopes: ["gmail.send"], + }, + ]); + }); + + test("a listing that cannot be read is unknown, never needs-consent", async () => { + const n = await run("personal"); + answer = async () => { + throw new Error(`vendor said no: token gho_secret_${suite}`); + }; + const response = await grants(n.viewer, "user", n.id); + expect(response.status).toBe(200); + const text = await response.text(); + const body = JSON.parse(text) as { actions: GrantState[] }; + expect(body.actions.length).toBeGreaterThan(0); + for (const entry of body.actions) expect(entry.state).toBe("unknown"); + expect(text).not.toContain("needs-consent"); + expect(text).not.toContain("gho_secret"); + }); +}); + +test("a Composio deployment answers this route successfully", async () => { + const n = await run("personal", "composio"); + answer = async () => [ + { action: "GMAIL_FETCH", state: "needs-consent", scopes: [] }, + ]; + const response = await grants(n.viewer, "user", n.id, "composio"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + actions: [{ action: "GMAIL_FETCH", state: "needs-consent", scopes: [] }], + }); + expect( + calls.filter((c) => c.toolkit === n.toolkit).map((c) => c.account), + ).toEqual([{ holder: "person", userId: n.viewer, vendorUserId: n.viewer }]); +}); From 77fa17cbef9f0dde1edaa3b849d0c9c7620b71f6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:29:02 +0200 Subject: [PATCH 086/232] test(arcade): the three person-site assertions pin the exact unconfigured sentence A negative assertion passes for any sentence at all. Connect, confirm/disconnect and recheck now assert the sentence itself; the absence assertions stay beside it. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/plugin-routes.test.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 418be51c6..034c3afd9 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -21,6 +21,14 @@ import { } from "../src/plugins/store"; import { testEnvironment } from "./support/environment"; +/** + * The sentence a PERSON is shown where no broker is configured, written out rather than imported + * from the class that makes it: a test that read it back from the source would pass whatever the + * source said. Identical on both providers, and it names no vendor and no setting. + */ +const PERSON_UNCONFIGURED = + "Connected apps are not set up for this deployment, so nothing was asked. An administrator can set them up."; + /** * The handful of reads one route makes, typed against the store that really answers them. * @@ -2164,9 +2172,9 @@ describe("connecting a brokered app", () => { const response = await connect({}); expect(response.status).toBe(503); - expect((await response.json()).error).not.toMatch( - /composio|COMPOSIO_API_KEY/i, - ); + const refusal = (await response.json()).error as string; + expect(refusal).toBe(PERSON_UNCONFIGURED); + expect(refusal).not.toMatch(/composio|COMPOSIO_API_KEY/i); }); test("the address Composio sends somebody back to is built here, never taken from the request", async () => { @@ -3057,6 +3065,7 @@ describe("confirming and ending a brokered connection", () => { (await confirmResponse.json()).error as string, (await disconnectResponse.json()).error as string, ]) { + expect(refusal).toBe(PERSON_UNCONFIGURED); expect(refusal).not.toContain("COMPOSIO_API_KEY"); expect(refusal).not.toMatch(/composio/i); } @@ -3207,9 +3216,9 @@ describe("re-checking a brokered connection", () => { const response = await recheck(); expect(response.status).toBe(503); - expect((await response.json()).error).not.toMatch( - /composio|COMPOSIO_API_KEY/i, - ); + const refusal = (await response.json()).error as string; + expect(refusal).toBe(PERSON_UNCONFIGURED); + expect(refusal).not.toMatch(/composio|COMPOSIO_API_KEY/i); expect(rechecked).toEqual([]); }); From f34a6603bd9ef36b2836d5b99dd4dd66a9e9b816 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:29:02 +0200 Subject: [PATCH 087/232] docs(arcade): broker.ts's header no longer claims its crossing imports are all types; record the access.ts cycle invariant Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/broker.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 4d793e8a2..70259bb8c 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -10,9 +10,22 @@ * schema. Everything below is a name for a shape, so the vendor's package stays confined to the * adapter that implements {@link ConnectedAppBroker} — one file, replaceable, and the only place a * version bump can reach. A module that named the vendor's types here would put their package on - * the import graph of every test that touches enablement. `AccountRef` and `BrokerProvider`, below, are the only imports - * that cross this file's boundary, and both are types this deployment names, never the vendor's. + * the import graph of every test that touches enablement. `AccountRef`, `BrokerProvider` and + * `BROKERED_PROFILE`, below, are the only imports that cross this file's boundary, and all three are + * names this deployment owns, never the vendor's. Two are types; `BROKERED_PROFILE` is a value, and + * the comment on its import says what that costs. */ +// This import closes a runtime cycle: `./access` imports `schemeKind` from this file. It is safe +// for two reasons, and the reasons are the rule. `access.ts` reads `schemeKind` only inside a +// function body, and this file reads `BROKERED_PROFILE` only inside the `BrokerUnconfiguredError` +// constructor, so whichever module evaluates first, the other's half-initialised bindings are never +// touched while either is still evaluating. Nothing may call across this cycle at module scope. +// Hoisting the `BROKERED_PROFILE[provider]` destructure out of that constructor, or deriving a +// top-level constant in `access.ts` from `schemeKind`, would read a lexical binding the other module +// has not yet initialised: it throws under one evaluation order and not the other, so it can pass +// every test and still fail at boot. The cycle is deliberate: passing `vendor` and `envVar` in +// would scatter the vocabulary across every call site, which is what keeping `BROKERED_PROFILE` in +// `access.ts` exists to prevent. import { BROKERED_PROFILE, type BrokerProvider } from "./access"; import type { AccountRef } from "./shared-accounts"; From 973306070690b28108ebeaa3e42394d2fd38ae10 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:31:00 +0200 Subject: [PATCH 088/232] feat(arcade): GET /servers/:id/connection/siblings names what a disconnect would also end The disconnect confirmation needs the list before the press. The new route answers { siblings, unanswered } straight from the store's siblingApps, keyed on the pair (vendor identity, auth provider), for the same account the DELETE resolves. DELETE /servers/:id/connection already runs the cascade through disconnectBrokered (revoke first, throw with rows standing, boolean passed through), so it is unchanged; tests drive both through createApp and pin the rows and the audit trail, including that Composio names no siblings. Scoping: every case mints its own suite+counter suffix on each app, person and vendor identity; rows and trail are read via inArray on that case's own names only, as arcade-sibling-collateral.test.ts does. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 46 +++ server/tests/arcade-disconnect-route.test.ts | 282 +++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 server/tests/arcade-disconnect-route.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 1ed43c3a8..fc8a53615 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2407,6 +2407,52 @@ export function createPluginRoutes( } }); + /** + * WHAT DISCONNECTING THIS APP WOULD END BESIDES IT, for the confirmation to show before the + * press. Answers `{ siblings, unanswered }`: the other apps' toolkits, sorted, and how many apps + * on the same vendor identity could not be asked about and so are left out (a count, never + * names). It acts on nothing: no revoke, no delete, no audit row. + * + * THE SET IS THE STORE'S, KEYED ON THE PAIR `(vendor identity, auth provider)` AND NEVER ON THE + * PROVIDER ALONE — see `siblingApps`. Nothing is derived here, so this route and the cascade + * `DELETE /servers/:id/connection` runs cannot disagree about which apps are named. On Composio + * an app is its own auth provider, so the answer is always empty. + * + * THE ACCOUNT COMES FROM `brokeredAccountFor`, exactly as on the DELETE it previews: the list is + * about the account that press would end, so it is asked of the same resolution and is refused + * to the same callers. + */ + routes.get( + "/servers/:id/connection/siblings", + requireUser, + async (context) => { + const resolved = await brokeredAccountFor( + context, + context.req.param("id"), + ); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + try { + return context.json( + await store.siblingApps({ + toolkit: resolved.toolkit, + account: resolved.account, + }), + ); + } catch (error) { + const refusal = brokerRefusal( + error, + "This deployment could not work out which other apps share this connection, and the vendor said nothing about why. Try again.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }, + ); + /** * Begin authorizing ONE ACTION of a brokered app, for a provider that grants action by action. * Answers `{ authorizationUrl }` and nothing else. diff --git a/server/tests/arcade-disconnect-route.test.ts b/server/tests/arcade-disconnect-route.test.ts new file mode 100644 index 000000000..03f0cd6f4 --- /dev/null +++ b/server/tests/arcade-disconnect-route.test.ts @@ -0,0 +1,282 @@ +// server/tests/arcade-disconnect-route.test.ts +/** + * `GET /api/plugins/servers/:id/connection/siblings` and `DELETE /api/plugins/servers/:id/connection`, + * driven through `createApp`: what ending one app's connection ends besides it, and the cascade. + * + * THE ROWS AND THE TRAIL ARE ASSERTED, never a response body alone. + * + * SCOPING. `audit_events` is append-only and nothing here is cleaned from it, so every case mints + * its own `run()`: a fresh `suite+counter` suffix on every app, person and vendor identity it + * touches. Rows and trail are read through `inArray` on THAT case's app names only, and the + * "untouched" and "nothing audited" assertions therefore name keys no other case writes — an + * unwanted deletion or audit row for one of them could only have come from this case. + */ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, asc, eq, inArray } from "drizzle-orm"; +import { createApp } from "../src/app"; +import { createAuditStore } from "../src/audit"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { auditEvents, brokeredConnections, mcpServers } from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +let counter = 0; + +type Provider = "arcade" | "composio"; +let revokes: string[] = []; +let revokeAnswer: boolean | Error = true; + +const providerOf = (provider: Provider) => (toolkit: string) => + provider === "composio" + ? toolkit + : toolkit.startsWith("Slack") + ? "slack" + : "google"; + +function brokerFor(provider: Provider): ConnectedAppBroker { + return { + grantIsLocal: false, + authProviderOf: async (toolkit) => providerOf(provider)(toolkit), + authorizations: null, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), + isConnected: async () => true, + revoke: async (request) => { + revokes.push(request.toolkit); + if (revokeAnswer instanceof Error) throw revokeAnswer; + return revokeAnswer; + }, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => null, + }; +} + +function build(who: string, provider: Provider) { + const broker = brokerFor(provider); + const auditStore = createAuditStore(database); + const pluginStore = createPluginStore({ + brokerProvider: provider, + database, + auditStore, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + const args = new Array(createApp.length) as unknown as Parameters< + typeof createApp + >; + args[0] = loadConfig( + testEnvironment({ OPENBOT_APP_URL: "https://app.example" }), + ); + args[1] = { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => ({ + user: { id: who, email: `${who}@example.test`, name: "Person" }, + }), + }, + } as never; + args[2] = { rolesForUser: async () => ["user"] } as never; + args[12] = auditStore; + args[14] = pluginStore; + args[29] = { broker, provider }; + return createApp(...args); +} + +const toolkits: string[] = []; + +/** This case's own names. Nothing another case writes can share one. */ +function run(provider: Provider = "arcade") { + counter += 1; + const tag = `${suite}${counter}`; + const names = { + gmail: `Gmail${tag}`, + calendar: `GoogleCalendar${tag}`, + docs: `GoogleDocs${tag}`, + slack: `Slack${tag}`, + person: `person-${tag}`, + shared: `openbot-deployment:acme:${tag}`, + }; + toolkits.push(names.gmail, names.calendar, names.docs, names.slack); + revokes = []; + revokeAnswer = true; + return { ...names, provider, id: (toolkit: string) => `srv-${toolkit}` }; +} + +async function app(n: ReturnType, toolkit: string) { + await database.insert(mcpServers).values({ + id: n.id(toolkit), + title: toolkit, + vendor: n.provider === "arcade" ? "Arcade" : "Composio", + url: `${n.provider}://${toolkit}`, + provenance: n.provider, + authScheme: "OAUTH2", + accountMode: "personal", + }); +} +async function hold( + n: ReturnType, + toolkit: string, + vendorUserId: string, + holder: "person" | "deployment" = "person", +) { + await database.insert(brokeredConnections).values({ + provider: n.provider, + app: toolkit, + holder, + userId: holder === "person" ? vendorUserId : null, + vendorUserId, + }); +} +const rowsFor = async (names: string[]) => + ( + await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where(inArray(brokeredConnections.app, names)) + .orderBy(asc(brokeredConnections.app)) + ).map((row) => row.app); +const trailFor = async (names: string[]) => + ( + await database + .select({ app: auditEvents.targetId }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_disconnected"), + inArray(auditEvents.targetId, names), + ), + ) + .orderBy(asc(auditEvents.targetId)) + ).map((row) => row.app); + +const preview = (n: ReturnType, toolkit: string) => + build(n.person, n.provider).request( + `http://openbot.test/api/plugins/servers/${n.id(toolkit)}/connection/siblings`, + ); +const disconnect = (n: ReturnType, toolkit: string) => + build(n.person, n.provider).request( + `http://openbot.test/api/plugins/servers/${n.id(toolkit)}/connection`, + { method: "DELETE" }, + ); + +afterAll(async () => { + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, toolkits)); + await database.delete(mcpServers).where( + inArray( + mcpServers.id, + toolkits.map((t) => `srv-${t}`), + ), + ); +}); + +describe("the sibling list the confirmation shows", () => { + test("is computed on the PAIR: another identity's app on the same provider is not named", async () => { + const n = run(); + for (const t of [n.gmail, n.calendar, n.docs, n.slack]) await app(n, t); + await hold(n, n.gmail, n.person); + await hold(n, n.calendar, n.person); // same identity, same provider: a sibling + await hold(n, n.docs, n.shared, "deployment"); // same provider, ANOTHER identity + await hold(n, n.slack, n.person); // same identity, ANOTHER provider + + const response = await preview(n, n.gmail); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + siblings: [n.calendar], + unanswered: 0, + }); + // Asking changes nothing: no revoke, no row gone. + expect(revokes).toEqual([]); + expect(await rowsFor([n.gmail, n.calendar, n.docs, n.slack])).toEqual( + [n.gmail, n.calendar, n.docs, n.slack].sort(), + ); + }); +}); + +describe("the disconnect", () => { + test("removes and audits every app that ended, and nothing on another pair", async () => { + const n = run(); + for (const t of [n.gmail, n.calendar, n.docs, n.slack]) await app(n, t); + await hold(n, n.gmail, n.person); + await hold(n, n.calendar, n.person); + await hold(n, n.docs, n.shared, "deployment"); + await hold(n, n.slack, n.person); + + const response = await disconnect(n, n.gmail); + + expect(response.status).toBe(200); + expect(revokes).toEqual([n.gmail]); // ONE ask to the vendor + expect(await rowsFor([n.gmail, n.calendar, n.docs, n.slack])).toEqual( + [n.docs, n.slack].sort(), + ); + expect(await trailFor([n.gmail, n.calendar, n.docs, n.slack])).toEqual( + [n.gmail, n.calendar].sort(), + ); + }); + + test("a partial vendor failure leaves EVERY row standing and audits nothing", async () => { + const n = run(); + for (const t of [n.gmail, n.calendar]) await app(n, t); + await hold(n, n.gmail, n.person); + await hold(n, n.calendar, n.person); + revokeAnswer = new Error("revoke failed halfway"); + + const response = await disconnect(n, n.gmail); + + expect(response.status).toBeGreaterThanOrEqual(500); + expect(revokes).toEqual([n.gmail]); + expect(await rowsFor([n.gmail, n.calendar])).toEqual( + [n.gmail, n.calendar].sort(), + ); + expect(await trailFor([n.gmail, n.calendar])).toEqual([]); + }); + + test("answers what the vendor was asked, passing a false through unrewritten", async () => { + const n = run(); + await app(n, n.gmail); + await hold(n, n.gmail, n.person); + revokeAnswer = false; + + const response = await disconnect(n, n.gmail); + + expect(await response.json()).toEqual({ vendorRevocationRequested: false }); + expect(await rowsFor([n.gmail])).toEqual([]); + }); +}); + +describe("Composio, where an app is its own auth provider", () => { + test("names no siblings and disconnects exactly the one app", async () => { + const n = run("composio"); + for (const t of [n.gmail, n.calendar]) await app(n, t); + await hold(n, n.gmail, n.person); + await hold(n, n.calendar, n.person); + + const listed = await preview(n, n.gmail); + expect(await listed.json()).toEqual({ siblings: [], unanswered: 0 }); + + const response = await disconnect(n, n.gmail); + + expect(response.status).toBe(200); + expect(await rowsFor([n.gmail, n.calendar])).toEqual([n.calendar]); + expect(await trailFor([n.gmail, n.calendar])).toEqual([n.gmail]); + }); +}); From bb458f053364ae9734aceed38037b67802b566f0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:31:48 +0200 Subject: [PATCH 089/232] feat(arcade): the pre-redirect consent copy names the permission and lists the actions that share it, grouped on scopes An unscoped app says it cannot separate permissions and counts the actions it approves; neither sentence promises that only the named action becomes usable. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/plugins/enablement-notes.ts | 72 +++++++++++++++++ app/tests/arcade-consent-copy.test.tsx | 102 ++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 app/tests/arcade-consent-copy.test.tsx diff --git a/app/src/lib/plugins/enablement-notes.ts b/app/src/lib/plugins/enablement-notes.ts index 7791c2988..f7e98c052 100644 --- a/app/src/lib/plugins/enablement-notes.ts +++ b/app/src/lib/plugins/enablement-notes.ts @@ -92,3 +92,75 @@ export function consentBreadth( export function matchingApps(apps: ComposioApp[]): ComposioApp[] { return [...apps].sort((left, right) => left.name.localeCompare(right.name)); } + +/** One action of a provider's app, as far as consent is concerned: its name and the scopes it requests. */ +export type ConsentAction = { name: string; scopes: readonly string[] }; + +export type ConsentCopy = { + /** The permissions the requested action asks the provider for. Empty when it asks for none. */ + scopes: string[]; + /** The OTHER actions whose scopes overlap those, computed by grouping on `scopes`. */ + alsoEnables: string[]; + /** What to say before redirecting somebody to the consent screen. */ + sentence: string; +}; + +/** + * What to say before sending somebody to a consent screen for `requested`. + * + * THE LIST IS GROUPED ON `scopes` AND NEVER WORDED BY HAND. What a person grants is an OAuth scope; + * the action is only how they chose which scope to be asked for. Two actions that request an + * identical scope set (`Gmail.ListEmails` and `Gmail.ListThreads`) are authorized together, so the + * copy names the permission and lists every other action requiring any of it. + * + * IT NEVER PROMISES THAT ONLY THE NAMED ACTION BECOMES USABLE. Whether a consent isolates is the + * provider's property, measured per app (Google isolated 11 of 30; GitHub 43 of 43), so an app with + * no scopes says it cannot separate permissions at all. Scope answers "may this deployment ever read + * your mail"; the tool grant answers "may this Bot call it", and no consent widens that. The last + * clause keeps the two apart. + */ +export function consentCopy( + requested: string, + actions: readonly ConsentAction[], +): ConsentCopy { + const asked = actions.find((action) => action.name === requested); + const scopes = asked ? [...new Set(asked.scopes)].sort() : []; + const grants = + "Which Bots may call an action is still set separately, and approving does not change it."; + if (!actions.some((action) => action.scopes.length > 0)) { + const all = actions.length; + return { + scopes, + alsoEnables: [], + sentence: + all === 1 + ? `This app cannot separate permissions; approving this approves its single action. ${grants}` + : `This app cannot separate permissions; approving this approves all ${all}. ${grants}`, + }; + } + if (scopes.length === 0) { + return { + scopes, + alsoEnables: [], + sentence: `${requested} requests no permission of its own; approving connects the account. ${grants}`, + }; + } + const alsoEnables = actions + .filter( + (action) => + action.name !== requested && + action.scopes.some((scope) => scopes.includes(scope)), + ) + .map((action) => action.name) + .sort(); + const permission = `${scopes.length === 1 ? "the permission" : "the permissions"} ${scopes.join(", ")}`; + const tail = + alsoEnables.length === 0 + ? "No other action requires it." + : `It also enables ${alsoEnables.length === 1 ? "this other action" : `these ${alsoEnables.length} other actions`} requiring it: ${alsoEnables.join(", ")}.`; + return { + scopes, + alsoEnables, + sentence: `Approving ${requested} asks the provider for ${permission}. ${tail} ${grants}`, + }; +} diff --git a/app/tests/arcade-consent-copy.test.tsx b/app/tests/arcade-consent-copy.test.tsx new file mode 100644 index 000000000..bb0c4b036 --- /dev/null +++ b/app/tests/arcade-consent-copy.test.tsx @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import fixture from "../../server/tests/fixtures/arcade-catalogue.json"; +import { + consentCopy, + type ConsentAction, +} from "@/lib/plugins/enablement-notes"; + +/** + * The copy shown before redirecting somebody to a consent screen (spec D30). The action list is + * DERIVED by grouping on `scopes`; the fixture is evidence and is never appended to, so shapes it + * does not carry (a shared scope across actions) are constructed inline. + */ + +type FixtureTool = { + qualified_name: string; + toolkit: { name: string }; + requirements?: { authorization?: { oauth2?: { scopes?: string[] } } }; +}; + +function fromFixture(toolkit: string): ConsentAction[] { + return (Object.values(fixture.tools) as FixtureTool[]) + .filter((tool) => tool.toolkit.name === toolkit) + .map((tool) => ({ + name: tool.qualified_name, + scopes: tool.requirements?.authorization?.oauth2?.scopes ?? [], + })); +} + +const gmail: ConsentAction[] = [ + { name: "Gmail.ListEmails", scopes: ["gmail.readonly"] }, + { name: "Gmail.ListThreads", scopes: ["gmail.readonly"] }, + { name: "Gmail.GetThread", scopes: ["gmail.readonly"] }, + { name: "Gmail.SendEmail", scopes: ["gmail.send"] }, +]; + +test("scoped: names the permission and lists the actions grouped on that scope", () => { + const copy = consentCopy("Gmail.ListEmails", gmail); + expect(copy.scopes).toEqual(["gmail.readonly"]); + expect(copy.alsoEnables).toEqual(["Gmail.GetThread", "Gmail.ListThreads"]); + expect(copy.sentence).toContain("gmail.readonly"); + expect(copy.sentence).toContain("also enables"); + expect(copy.sentence).toContain("Gmail.GetThread, Gmail.ListThreads"); + expect(copy.sentence).not.toContain("Gmail.SendEmail"); + // The list follows the data: change a scope and the list changes with it. + const moved = gmail.map((a) => + a.name === "Gmail.GetThread" ? { ...a, scopes: ["gmail.modify"] } : a, + ); + expect(consentCopy("Gmail.ListEmails", moved).alsoEnables).toEqual([ + "Gmail.ListThreads", + ]); +}); + +test("scoped, from the fixture: a lone scope set lists no sibling", () => { + const asana = fromFixture("Asana"); + const copy = consentCopy(asana[0]!.name, asana); + expect(copy.scopes).toEqual(["default"]); + expect(copy.alsoEnables).toEqual([]); + expect(copy.sentence).toContain("No other action requires it."); +}); + +test("unscoped: all-or-nothing, with the count derived", () => { + const apollo = fromFixture("Apollo"); + expect(consentCopy(apollo[0]!.name, apollo).sentence).toContain( + "approving this approves its single action", + ); + const forty3 = Array.from({ length: 43 }, (_, i) => ({ + name: `Github.Action${i}`, + scopes: [], + })); + expect(consentCopy("Github.Action0", forty3).sentence).toContain( + "This app cannot separate permissions; approving this approves all 43.", + ); + expect(consentCopy("Github.Action0", forty3.slice(0, 7)).sentence).toContain( + "approves all 7.", + ); +}); + +test("an unscoped action in a scoped app is not worded as a permission", () => { + const airtable = fromFixture("AirtableApi"); + const bare = airtable.find((a) => a.scopes.length === 0)!; + const copy = consentCopy(bare.name, airtable); + expect(copy.scopes).toEqual([]); + expect(copy.sentence).toContain("requests no permission of its own"); +}); + +test("no copy promises confinement", () => { + const shapes = [ + consentCopy("Gmail.ListEmails", gmail), + consentCopy("Gmail.SendEmail", gmail), + consentCopy("Apollo.X", [{ name: "Apollo.X", scopes: [] }]), + consentCopy( + "G.A", + Array.from({ length: 43 }, (_, i) => ({ name: `G.A${i}`, scopes: [] })), + ), + consentCopy("Missing", gmail), + ]; + for (const shape of shapes) { + expect(shape.sentence).not.toMatch( + /confin|only (the|that|this)|just (the|that|this)|limited to|isolat|least.privilege|restricted to|nothing else/i, + ); + } +}); From bb6ce4f0ace963fa2914c042e65af969920913bb Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:36:44 +0200 Subject: [PATCH 090/232] feat(arcade): the connected-account page names the sibling apps a disconnect would end, speaks the active broker's name, and lands on the authorized action Co-Authored-By: Claude Opus 5 (1M context) --- .../plugins/brokered-account-row.tsx | 23 +- app/src/lib/plugins/provider-vocabulary.ts | 61 +++ app/src/lib/plugins/queries.ts | 36 ++ .../settings/connected-accounts/$key.tsx | 95 ++++- .../connected-account-confirmation.test.tsx | 389 ++++++++++++++++++ server/src/plugins/routes.ts | 4 +- server/tests/arcade-verify.test.ts | 2 +- 7 files changed, 598 insertions(+), 12 deletions(-) create mode 100644 app/tests/connected-account-confirmation.test.tsx diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index c5b4447db..ed89d9b15 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -17,6 +17,11 @@ import { ItemDescription, ItemTitle, } from "@/components/ui/item"; +import { + type PluginsProvider, + brokeredKeyMissing, + providerVocabulary, +} from "@/lib/plugins/provider-vocabulary"; import { type BrokerField, brokeredConnectionFieldsMutationOptions, @@ -685,17 +690,20 @@ function accountSentence(input: { connectedDescription: string; disconnectedDescription: string; disconnectedReassurance: string | undefined; + provider: PluginsProvider; }): string { const { account, connectedDescription, disconnectedDescription, disconnectedReassurance, + provider, title, } = input; + const vendor = providerVocabulary(provider).name; if (!account.configured) { - return "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."; + return brokeredKeyMissing(provider); } if (account.kind === "no-auth") { @@ -735,7 +743,7 @@ function accountSentence(input: { * is worth a second press rather than a second connection. */ if (account.probe && !account.verified) { - return `A check against ${title} was made with your key and did not come back clean — that may be the key, and it may be ${title} itself. The account still stands at Composio, so press Re-check to try again, or disconnect it here and connect again with a fresh key.`; + return `A check against ${title} was made with your key and did not come back clean — that may be the key, and it may be ${title} itself. The account still stands at ${vendor}, so press Re-check to try again, or disconnect it here and connect again with a fresh key.`; } /* * NOTHING WAS SPENT ON IT, WHICH IS A FACT ABOUT THE CHECK AND ABOUT THE APP AT THE TIME. The @@ -775,7 +783,7 @@ function accountSentence(input: { * have live, so the row names the step this deployment cannot take for them. */ if (account.disconnected) { - return `Removed from Composio. Your key still works at ${title} — rotate it there if you meant to end its access.`; + return `Removed from ${vendor}. Your key still works at ${title} — rotate it there if you meant to end its access.`; } /* * NOT THE SCREEN'S OWN SENTENCE, which is written for the kind that leaves: one of the two says @@ -818,6 +826,7 @@ export function BrokeredAccountRow({ disconnectedReassurance, heading, notice, + provider = "composio", title, }: { account: BrokeredAccount; @@ -849,6 +858,13 @@ export function BrokeredAccountRow({ * that it is shared, say — which holds regardless of which of those two is showing. */ notice?: string; + /** + * Which broker's name the sentences that name one use. A NAME, never a key or a capability. + * + * Defaults to Composio so a screen that has not been taught about the second broker renders as it + * always did; a screen on an Arcade deployment passes its own. + */ + provider?: PluginsProvider; /** * The app's own name, for the sentences that name it. * @@ -896,6 +912,7 @@ export function BrokeredAccountRow({ connectedDescription, disconnectedDescription, disconnectedReassurance, + provider, title, })} diff --git a/app/src/lib/plugins/provider-vocabulary.ts b/app/src/lib/plugins/provider-vocabulary.ts index 902452891..cbcffe0de 100644 --- a/app/src/lib/plugins/provider-vocabulary.ts +++ b/app/src/lib/plugins/provider-vocabulary.ts @@ -143,6 +143,67 @@ export function brokeredDisconnectNote(provider: PluginsProvider): string { return `Disconnecting ends the account at ${VOCABULARY[provider].name}, not just here.`; } +/** + * The line under a brokered app's own page title on a person's connected-accounts screen. + * + * NOT {@link brokeredConnectionDescription}, which is the admin Connection card's sentence and says + * something different ("each person's own account"). This one speaks to the person reading their + * own page: the broker holds the account, so a Bot sees only what they can see. + */ +export function brokeredAccountPageDescription( + provider: PluginsProvider, +): string { + return `Reached through ${VOCABULARY[provider].name}, which holds the account, so a Bot sees only what you can see.`; +} + +/** What Connect does on a consent app, said before the press: where it goes first. */ +export function brokeredConsentTrip(provider: PluginsProvider): string { + return `Connecting takes you to ${VOCABULARY[provider].name} and then to the vendor to consent.`; +} + +/** What a person is told where the deployment has no key for the active broker. */ +export function brokeredKeyMissing(provider: PluginsProvider): string { + return `Set ${VOCABULARY[provider].envVar} on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands.`; +} + +/** + * What a Disconnect would also end, said beside the Disconnect, from the server's computed answer. + * + * NULL WHEN THERE IS NOTHING TO SAY. An app with no siblings gets no warning block at all, not an + * empty one: a warning about apps that will not be touched teaches people to dismiss the one that + * matters. `unanswered` is a COUNT and never names, because an app that could not be asked about + * has no name this deployment could vouch for. + * + * It names the apps and promises nothing about confinement (D30): which other apps a consent covers + * is the provider's property, and this sentence reports what the server counted rather than what it + * hopes. + */ +export function disconnectSiblingsNotice(answer: { + siblings: readonly string[]; + unanswered: number; +}): string | null { + const { siblings, unanswered } = answer; + const parts: string[] = []; + if (siblings.length > 0) { + parts.push( + `Disconnecting also ends your access to ${joinNames(siblings)}, which ${siblings.length === 1 ? "shares" : "share"} this connection.`, + ); + } + if (unanswered > 0) { + parts.push( + siblings.length > 0 + ? `${unanswered} other ${unanswered === 1 ? "app" : "apps"} could not be checked and may be affected too.` + : `${unanswered} ${unanswered === 1 ? "app" : "apps"} on this connection could not be checked, so disconnecting may end ${unanswered === 1 ? "it" : "them"} too.`, + ); + } + return parts.length > 0 ? parts.join(" ") : null; +} + +function joinNames(names: readonly string[]): string { + if (names.length <= 2) return names.join(" and "); + return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; +} + /** * Whether a directory route may answer on this deployment. * diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index b4d7195ce..e47e79c40 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -263,6 +263,7 @@ export const pluginKeys = { page: () => ["plugins", "page"] as const, forAgent: (agentId: string) => ["plugins", "for-agent", agentId] as const, connections: () => ["plugins", "connections"] as const, + siblings: (serverId: string) => ["plugins", "siblings", serverId] as const, composioApps: (query: string) => ["plugins", "composio", "apps", query] as const, }; @@ -415,6 +416,41 @@ export function connectionsQueryOptions() { }); } +/** + * What disconnecting this app would also end, as the server computed it. + * + * `siblings` are other apps' toolkit names (never this one) and `unanswered` is a COUNT of apps that + * could not be asked about. A deployment whose broker holds one account per app always answers + * empty. + */ +export type ConnectionSiblings = { siblings: string[]; unanswered: number }; + +export function connectionSiblingsQueryOptions(serverId: string) { + return queryOptions({ + queryKey: pluginKeys.siblings(serverId), + queryFn: async (): Promise => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connection/siblings`, + { + fallback: "Which other apps share this connection could not be read.", + }, + ); + const body: Partial = await response.json(); + /* + * NARROWED RATHER THAN TRUSTED: an answer that is not this shape is read as "nothing to say", + * which draws no warning, rather than a throw that takes the whole page down for a sentence. + */ + return { + siblings: Array.isArray(body.siblings) ? body.siblings : [], + unanswered: + typeof body.unanswered === "number" && body.unanswered > 0 + ? body.unanswered + : 0, + }; + }, + }); +} + export function pluginsPageQueryOptions() { return queryOptions({ queryKey: pluginKeys.page(), diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index b507c54cc..25b5eeef3 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -1,6 +1,6 @@ import { IconArrowUpRight, IconChevronDown } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { createFileRoute, useParams } from "@tanstack/react-router"; +import { createFileRoute, useParams, useSearch } from "@tanstack/react-router"; import { useState } from "react"; import { PageEmpty, @@ -29,11 +29,20 @@ import { import { Separator } from "@/components/ui/separator"; import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; import { + connectionSiblingsQueryOptions, connectionsQueryOptions, type PluginServer, personalConnections, pluginsPageQueryOptions, + pluginsProvider, } from "@/lib/plugins/queries"; +import { + brokeredAccountPageDescription, + brokeredConsentTrip, + brokeredDisconnectNote, + brokeredProvenance, + disconnectSiblingsNotice, +} from "@/lib/plugins/provider-vocabulary"; /** * Whether this server is reached through Composio AS THIS PERSON. @@ -44,7 +53,38 @@ import { * pin the distinction without rendering the page. */ export function brokeredFor(server: PluginServer | undefined): boolean { - return server?.provenance === "composio" && server.accountMode !== "shared"; + /* + * A FILTER, so it asks whether the row came through a broker at all and needs no vendor name: one + * provider holds rows at a time (spec D3). Naming the vendor on this page is a different question + * and reads the active provider, below. + */ + return ( + server !== undefined && + brokeredProvenance(server.provenance) && + server.accountMode !== "shared" + ); +} + +/** The longest action name this page will repeat back. Real ones are far shorter. */ +const MAX_LANDED_ACTION = 200; + +/** + * The action the verifier's redirect says was just authorized, or null. + * + * A TRIMMED, BOUNDED NAME AND NOTHING ELSE: it is read off a query string, which anybody can write + * on this origin, so it is only ever drawn as a quoted name inside this page's own sentence and + * never as a sentence of its own. + */ +export function landedActionFrom(action: string | undefined): string | null { + const trimmed = (action ?? "").trim(); + return trimmed.length > 0 && trimmed.length <= MAX_LANDED_ACTION + ? trimmed + : null; +} + +/** What the landing says about the action somebody has just come back from authorizing. */ +export function landedActionNotice(action: string): string { + return `You just authorized ${action}.`; } /** @@ -57,12 +97,24 @@ export function brokeredFor(server: PluginServer | undefined): boolean { */ export const Route = createFileRoute( "/_authed/settings/connected-accounts/$key", -)({ component: RouteComponent }); +)({ + component: RouteComponent, + /* + * `?action=` is the verifier's redirect saying WHICH action the consent just completed was for, + * so the page can land on it rather than on the app. The key is omitted when absent rather than + * set to undefined, which would make `search` a required prop on every Link to this route. + */ + validateSearch: (search: Record): { action?: string } => + typeof search.action === "string" ? { action: search.action } : {}, +}); function RouteComponent() { const { key } = useParams({ from: "/_authed/settings/connected-accounts/$key", }); + const { action } = useSearch({ + from: "/_authed/settings/connected-accounts/$key", + }); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); const [notice, setNotice] = useState(null); @@ -109,6 +161,25 @@ function RouteComponent() { * only thing here that knows. */ const brokered = brokeredFor(server); + /* + * THE VENDOR'S NAME, from the active provider and never from a row: a deployment with the key set + * and nothing enabled has no row, and a row cannot say which broker holds the account. + */ + const provider = pluginsProvider(plugins.data); + /* + * WHAT A DISCONNECT WOULD ALSO END, asked only while there is an account for it to end. The query + * is gated on the connection and its answer is read only through the same gate, so a cached answer + * from before a disconnect is never drawn beside "Connect". + */ + const siblings = useQuery({ + ...connectionSiblingsQueryOptions(key), + enabled: brokered && connection !== undefined, + }); + const siblingsNotice = + brokered && connection !== undefined && siblings.data + ? disconnectSiblingsNotice(siblings.data) + : null; + const landedAction = landedActionFrom(action); /* Everything the brokered row below reads and does. See `brokered-account-row.tsx`. */ const brokeredAccount = useBrokeredAccount({ @@ -251,7 +322,7 @@ function RouteComponent() { return ( {notice ? ( @@ -260,6 +331,16 @@ function RouteComponent() {

) : null} + {landedAction ? ( +

+ {landedActionNotice(landedAction)} +

+ ) : null} + {/* One decision, so no heading: it would only repeat the row's own title. */} @@ -268,12 +349,14 @@ function RouteComponent() { /* Said beside the button rather than after it: disconnecting ends the account at Composio, so what it undoes is not the row here but the grant on your own mailbox, and connecting again is a fresh consent. */ - connectedDescription={`A Bot granted its tools reads your ${server.title} as you. Disconnecting ends the account at Composio, not just here.`} - disconnectedDescription={`${reassurance} Connecting takes you to Composio and then to the vendor to consent.`} + connectedDescription={`A Bot granted its tools reads your ${server.title} as you. ${brokeredDisconnectNote(provider)}`} + disconnectedDescription={`${reassurance} ${brokeredConsentTrip(provider)}`} /* What is true either way. The trip to Composio is not — an app whose key somebody types never leaves this page — but which Bots can read this person's mail is the whole point of the screen and holds for both kinds. */ disconnectedReassurance={reassurance} + notice={siblingsNotice ?? undefined} + provider={provider} title={server.title} /> diff --git a/app/tests/connected-account-confirmation.test.tsx b/app/tests/connected-account-confirmation.test.tsx new file mode 100644 index 000000000..c3dd91485 --- /dev/null +++ b/app/tests/connected-account-confirmation.test.tsx @@ -0,0 +1,389 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import { + brokeredAccountPageDescription, + brokeredConsentTrip, + brokeredDisconnectNote, + brokeredKeyMissing, + disconnectSiblingsNotice, +} from "@/lib/plugins/provider-vocabulary"; +import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; +import { + landedActionFrom, + Route as ConnectedAccountRoute, +} from "@/routes/_authed/settings/connected-accounts/$key"; + +/** + * The page a person reads before deciding to grant or end access to a mailbox, on each broker. + * + * Three things are pinned, and the first two are RENDERED markup because a sentence computed and + * never drawn is not shipped: the sibling list names what a Disconnect would also end and draws + * NOTHING when there is none; an Arcade deployment's page never says Composio, in text or in + * attributes; and a Composio deployment's three vendor sentences are character-for-character what + * they were. The fourth is the landing parameter the verifier's redirect carries. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + // Let React's scheduler drain before the DOM globals it reads go away. + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); + +const APP = "gmail"; + +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} +const pristine = captureRouteState(ConnectedAccountRoute); +let snapshot: Record; +beforeEach(() => { + snapshot = captureRouteState(pristine); +}); +afterEach(() => { + restoreRouteState(ConnectedAccountRoute, snapshot); +}); + +type Provider = "composio" | "arcade"; + +function server(provider: Provider): PluginServer { + return { + authScheme: "OAUTH2", + id: APP, + title: "Gmail", + vendor: "Google", + url: provider === "arcade" ? "arcade://gmail" : "https://example.invalid", + summary: "Mail.", + docsUrl: "", + provenance: provider, + hasCredential: false, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + accountMode: null, + sharedVendorConnected: false, + tools: [], + withdrawn: [], + }; +} + +type Setup = { + provider: Provider; + connected: boolean; + configured?: boolean; + siblings?: { siblings: string[]; unanswered: number }; + /** Whether the siblings read answers 500. */ + siblingsFail?: boolean; +}; + +const siblingReads: string[] = []; + +function install(setup: Setup) { + siblingReads.length = 0; + const page: PluginsPage = { + catalogue: [], + servers: [server(setup.provider)], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured: setup.configured ?? true, + provider: setup.provider, + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); + global.fetch = (async (input: RequestInfo | URL) => { + const path = String(input).split("?")[0] ?? ""; + if (path === "/api/plugins") return json(page); + if (path.startsWith("/api/plugins/connections")) { + return json({ + connections: setup.connected + ? [ + { + serverId: APP, + scope: "", + connectedAt: "2026-09-10T00:00:00.000Z", + verified: true, + verifiedAt: null, + checkable: false, + }, + ] + : [], + redirectUri: null, + }); + } + if (path.endsWith("/connection/siblings")) { + siblingReads.push(path); + if (setup.siblingsFail) return json({ error: "no" }, 500); + return json(setup.siblings ?? { siblings: [], unanswered: 0 }); + } + if (path.endsWith("/connection/confirm")) + return json({ connected: setup.connected }); + return json({ error: "unexpected" }, 404); + }) as typeof fetch; +} + +function renderScreen(search = "") { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const settingsRoute = createRoute({ + path: "/settings", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/connected-accounts/", + getParentRoute: () => settingsRoute, + component: () => null, + }); + const wired = ( + ConnectedAccountRoute as unknown as { + update: (options: unknown) => typeof ConnectedAccountRoute; + } + ).update({ + id: "/connected-accounts/$key", + path: "/connected-accounts/$key", + getParentRoute: () => settingsRoute, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([settingsRoute.addChildren([indexRoute, wired])]), + ]), + history: createMemoryHistory({ + initialEntries: [`/settings/connected-accounts/${APP}${search}`], + }), + }); + return render( + + + , + ); +} + +/** Every string the page puts in front of anyone: its text AND its markup, attributes included. */ +function everythingRendered(view: ReturnType): string { + return `${view.container.textContent ?? ""}\n${view.container.innerHTML}`; +} + +const COMPOSIO_PAGE = + "Reached through Composio, which holds the account, so a Bot sees only what you can see."; +const COMPOSIO_DISCONNECT_NOTE = + "Disconnecting ends the account at Composio, not just here."; +const COMPOSIO_CONSENT_TRIP = + "Connecting takes you to Composio and then to the vendor to consent."; + +// ---- 1 and 2: the sibling list, rendered ---- + +test("a connected app with siblings NAMES each of them in the confirmation", async () => { + install({ + provider: "arcade", + connected: true, + siblings: { siblings: ["googlecalendar", "googledocs"], unanswered: 0 }, + }); + const view = renderScreen(); + await view.findByText(/googlecalendar/); + const text = view.container.textContent ?? ""; + expect(text).toContain("googlecalendar"); + expect(text).toContain("googledocs"); + expect(text).toContain( + "Disconnecting also ends your access to googlecalendar and googledocs, which share this connection.", + ); + expect(text).not.toContain("gmail,"); +}); + +test("with no siblings NO sibling copy appears at all, not an empty block", async () => { + install({ + provider: "arcade", + connected: true, + siblings: { siblings: [], unanswered: 0 }, + }); + const view = renderScreen(); + await view.findByText("Disconnect"); + // The read has happened, and answered empty. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(siblingReads.length).toBeGreaterThan(0); + const text = view.container.textContent ?? ""; + expect(text).not.toContain("also ends"); + expect(text).not.toContain("could not be checked"); + expect(text).not.toContain("share this connection"); + // No second description under the row's own: the notice slot is not drawn. + const descriptions = view.container.querySelectorAll( + '[data-slot="item-description"]', + ); + expect(descriptions.length).toBe(1); +}); + +test("a Composio deployment always answers empty, so it draws no sibling copy either", async () => { + install({ provider: "composio", connected: true }); + const view = renderScreen(); + await view.findByText("Disconnect"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(view.container.textContent).not.toContain("also ends"); +}); + +test("apps that could not be asked about are a count and never names", () => { + expect(disconnectSiblingsNotice({ siblings: [], unanswered: 0 })).toBeNull(); + expect(disconnectSiblingsNotice({ siblings: ["slack"], unanswered: 2 })).toBe( + "Disconnecting also ends your access to slack, which shares this connection. 2 other apps could not be checked and may be affected too.", + ); + expect(disconnectSiblingsNotice({ siblings: [], unanswered: 1 })).toBe( + "1 app on this connection could not be checked, so disconnecting may end it too.", + ); +}); + +test("the sibling list is not drawn once there is no account for a Disconnect to end", async () => { + install({ + provider: "arcade", + connected: false, + siblings: { siblings: ["googledocs"], unanswered: 0 }, + }); + const view = renderScreen(); + await view.findByText("Connect"); + expect(view.container.textContent).not.toContain("googledocs"); + expect(siblingReads.length).toBe(0); +}); + +// ---- 3: Arcade never says Composio ---- + +for (const connected of [true, false]) { + for (const configured of [true, false]) { + test(`on an Arcade deployment NO rendered string says Composio (connected=${connected}, configured=${configured})`, async () => { + install({ + provider: "arcade", + connected, + configured, + siblings: { siblings: ["googledocs"], unanswered: 1 }, + }); + const view = renderScreen("?action=Gmail.ListEmails"); + await view.findAllByText(/Arcade/); + if (connected && configured) await view.findByText(/googledocs/); + const rendered = everythingRendered(view); + expect(rendered).not.toMatch(/composio/i); + if (!configured) expect(rendered).toContain("ARCADE_API_KEY"); + }); + } +} + +test("on an Arcade deployment the three sentences name Arcade", () => { + expect(brokeredAccountPageDescription("arcade")).toBe( + "Reached through Arcade, which holds the account, so a Bot sees only what you can see.", + ); + expect(brokeredDisconnectNote("arcade")).toBe( + "Disconnecting ends the account at Arcade, not just here.", + ); + expect(brokeredConsentTrip("arcade")).toBe( + "Connecting takes you to Arcade and then to the vendor to consent.", + ); +}); + +// ---- 4: Composio is byte-identical ---- + +test("on Composio the three vendor sentences are exactly what they always were", () => { + expect(brokeredAccountPageDescription("composio")).toBe(COMPOSIO_PAGE); + expect(brokeredDisconnectNote("composio")).toBe(COMPOSIO_DISCONNECT_NOTE); + expect(brokeredConsentTrip("composio")).toBe(COMPOSIO_CONSENT_TRIP); + expect(brokeredKeyMissing("composio")).toBe( + "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands.", + ); +}); + +test("a connected Composio page renders its sentences verbatim", async () => { + install({ provider: "composio", connected: true }); + const view = renderScreen(); + await view.findByText(/Disconnecting ends the account/); + const text = view.container.textContent ?? ""; + expect(text).toContain(COMPOSIO_PAGE); + expect(text).toContain( + `Connected through Gmail's consent screen. A Bot granted its tools reads your Gmail as you. ${COMPOSIO_DISCONNECT_NOTE}`, + ); +}); + +test("a disconnected Composio page renders its sentences verbatim", async () => { + install({ provider: "composio", connected: false }); + const view = renderScreen(); + await view.findByText(/Connecting takes you to/); + const text = view.container.textContent ?? ""; + expect(text).toContain(COMPOSIO_PAGE); + expect(text).toContain( + `No Bot can read this as you. ${COMPOSIO_CONSENT_TRIP}`, + ); +}); + +// ---- 5: the landing parameter ---- + +test("a redirect carrying an action marks that action", async () => { + install({ provider: "arcade", connected: true }); + const view = renderScreen("?action=Gmail.ListEmails"); + const marked = await view.findByRole("status"); + expect(marked.textContent).toBe("You just authorized Gmail.ListEmails."); + expect(marked.getAttribute("data-landed-action")).toBe("Gmail.ListEmails"); +}); + +test("a landing without an action behaves as it did", async () => { + install({ provider: "arcade", connected: true }); + const view = renderScreen(); + await view.findByText("Disconnect"); + expect(view.queryByRole("status")).toBeNull(); + expect(view.container.textContent).not.toContain("just authorized"); +}); + +test("validateSearch omits the key when absent and keeps a string action", () => { + const validate = ( + ConnectedAccountRoute as unknown as { + options: { validateSearch: (s: Record) => object }; + } + ).options.validateSearch; + expect(validate({})).toEqual({}); + expect("action" in validate({})).toBe(false); + expect(validate({ action: 5 })).toEqual({}); + expect(validate({ action: "Gmail.ListEmails" })).toEqual({ + action: "Gmail.ListEmails", + }); +}); + +test("an empty or absurdly long action is not drawn", () => { + expect(landedActionFrom(undefined)).toBeNull(); + expect(landedActionFrom(" ")).toBeNull(); + expect(landedActionFrom("x".repeat(201))).toBeNull(); + expect(landedActionFrom(" Gmail.ListEmails ")).toBe("Gmail.ListEmails"); +}); diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index fc8a53615..856c3012d 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2974,11 +2974,11 @@ export function createPluginRoutes( * started from, as the mint's own return address does. */ return context.redirect( - connectedAccountsUrlFor( + `${connectedAccountsUrlFor( connect?.appUrl, { serverId: attempt.serverId }, fresh.account.holder === "deployment" ? "admin" : "settings", - ), + )}?action=${encodeURIComponent(attempt.action)}`, 303, ); } catch { diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 0e2b23f27..6966ab19b 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -198,7 +198,7 @@ const refusedTo = (reason: string) => `${APP_URL}/settings/connected-accounts?authorized=${reason}`; /** Where a completion lands: the app's own page, built from the attempt row's `server_id`. */ -const LANDING = `${APP_URL}/settings/connected-accounts/${APP}`; +const LANDING = `${APP_URL}/settings/connected-accounts/${APP}?action=${encodeURIComponent(ACTION)}`; /** * A refusal, named by its code and not merely by the fact that something redirected. From 3665183bc8cd3d29f53cfeb743a755d070f44713 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:41:30 +0200 Subject: [PATCH 091/232] fix(arcade): the admin app-detail page tells its brokered rows which provider is active Both BrokeredAccountRow call sites on the admin page now pass the provider the page already derives, so an Arcade deployment no longer shows an administrator COMPOSIO_API_KEY. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/routes/_authed/admin/plugins/$key.tsx | 2 + .../admin-brokered-row-provider.test.tsx | 192 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 app/tests/admin-brokered-row-provider.test.tsx diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx index ca17e1523..553a16059 100644 --- a/app/src/routes/_authed/admin/plugins/$key.tsx +++ b/app/src/routes/_authed/admin/plugins/$key.tsx @@ -817,6 +817,7 @@ function RouteComponent() { disconnectedDescription="No team account is connected, so Bots granted these tools are refused." heading="Team account" notice="Sign in as the team account, not your own." + provider={provider} title={title} /> ) : ( @@ -833,6 +834,7 @@ function RouteComponent() { screen — and an administrator still has to be told they are not the step that finishes the connector. */ disconnectedReassurance={reassurance} + provider={provider} title={title} /> )} diff --git a/app/tests/admin-brokered-row-provider.test.tsx b/app/tests/admin-brokered-row-provider.test.tsx new file mode 100644 index 000000000..bef224f3d --- /dev/null +++ b/app/tests/admin-brokered-row-provider.test.tsx @@ -0,0 +1,192 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; +import { Route as AdminPluginRoute } from "@/routes/_authed/admin/plugins/$key"; + +/** + * The admin app-detail page's brokered row, on each broker (spec D11). + * + * The row is drawn by `BrokeredAccountRow`, which defaults to Composio when not told the provider. + * The page already knows the provider, so it must tell the row — in both places it draws one (the + * personal row and the Shared-mode team row). Arcade is scanned over text AND markup; Composio's + * affected sentence is pinned as an exact string. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); + +const APP = "gmail"; + +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} +const pristine = captureRouteState(AdminPluginRoute); +let snapshot: Record; +beforeEach(() => { + snapshot = captureRouteState(pristine); +}); +afterEach(() => { + restoreRouteState(AdminPluginRoute, snapshot); +}); + +type Provider = "composio" | "arcade"; + +function server(provider: Provider, shared: boolean): PluginServer { + return { + authScheme: "OAUTH2", + id: APP, + title: "Gmail", + vendor: "Google", + url: provider === "arcade" ? "arcade://gmail" : "https://example.invalid", + summary: "Mail.", + docsUrl: "", + provenance: provider, + hasCredential: false, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + accountMode: shared ? "shared" : null, + sharedVendorConnected: false, + tools: [], + withdrawn: [], + }; +} + +function install(setup: { + provider: Provider; + shared: boolean; + configured: boolean; +}) { + const page: PluginsPage = { + catalogue: [], + servers: [server(setup.provider, setup.shared)], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured: setup.configured, + provider: setup.provider, + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); + global.fetch = (async (input: RequestInfo | URL) => { + const path = String(input).split("?")[0] ?? ""; + if (path === "/api/plugins") return json(page); + if (path.startsWith("/api/plugins/connections")) + return json({ connections: [], redirectUri: null }); + if (path.startsWith("/api/agents")) return json([]); + return json({ error: "unexpected" }, 404); + }) as typeof fetch; +} + +function renderScreen() { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const adminRoute = createRoute({ + path: "/admin", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/plugins/", + getParentRoute: () => adminRoute, + component: () => null, + }); + const wired = ( + AdminPluginRoute as unknown as { + update: (options: unknown) => typeof AdminPluginRoute; + } + ).update({ + id: "/plugins/$key", + path: "/plugins/$key", + getParentRoute: () => adminRoute, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([adminRoute.addChildren([indexRoute, wired])]), + ]), + history: createMemoryHistory({ initialEntries: [`/admin/plugins/${APP}`] }), + }); + return render( + + + , + ); +} + +/** Every string the page puts in front of anyone: its text AND its markup, attributes included. */ +function everythingRendered(view: ReturnType): string { + return `${view.container.textContent ?? ""}\n${view.container.innerHTML}`; +} + +/** Waits for the row only, whichever broker it names: the assertions below do the judging. */ +const WAIT_FOR_ROW = /^Set [A-Z]+_API_KEY on this deployment\./; + +const COMPOSIO_KEY_MISSING = + "Set COMPOSIO_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."; +const ARCADE_KEY_MISSING = + "Set ARCADE_API_KEY on this deployment. Without it there is no broker to reach, so this account can be neither connected nor ended from here. The app stays enabled and every grant on its tools still stands."; + +for (const shared of [false, true]) { + const row = shared ? "team row" : "personal row"; + + test(`on an Arcade deployment the admin page's brokered ${row} says nothing of Composio`, async () => { + install({ provider: "arcade", shared, configured: false }); + const view = renderScreen(); + await view.findByText(WAIT_FOR_ROW); + const rendered = everythingRendered(view); + expect(rendered).not.toMatch(/composio/i); + expect(rendered).not.toContain("COMPOSIO_API_KEY"); + expect(rendered).toContain(ARCADE_KEY_MISSING); + }); + + test(`on a Composio deployment the admin page's brokered ${row} renders its sentence verbatim`, async () => { + install({ provider: "composio", shared, configured: false }); + const view = renderScreen(); + await view.findByText(WAIT_FOR_ROW); + expect(view.container.textContent).toContain(COMPOSIO_KEY_MISSING); + }); +} From 14dd925ee90f926941c39223b0406b757a5b7dfb Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:43:13 +0200 Subject: [PATCH 092/232] docs(arcade): operator page for the Arcade broker Co-Authored-By: Claude Opus 5 (1M context) --- docs/plugins/arcade.md | 186 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/plugins/arcade.md diff --git a/docs/plugins/arcade.md b/docs/plugins/arcade.md new file mode 100644 index 000000000..114251486 --- /dev/null +++ b/docs/plugins/arcade.md @@ -0,0 +1,186 @@ +# Arcade + +Arcade is a broker, the same kind of thing Composio is: it holds people's accounts at other apps — +Gmail, Google Calendar, GitHub, Asana and more — and publishes each app's actions as tools this +deployment can call on somebody's behalf. A Bot with a brokered action granted reaches the app **as +the person asking**, so two people asking the same question get the answers their own accounts can +see. Where Arcade differs from Composio is the part an operator has to set up: **an Arcade project +needs an OAuth app of your own for every provider, and one manual step in Arcade's dashboard.** This +page is about those, and about what to tell somebody when a connection looks wrong. + +Read [Composio](composio.md) first if you have not: what a broker is, what Personal and Shared mean, +the approval rules and the audit trail are the same here and are not repeated. + +Three things on this page are stated as **unproven**, and the last section lists them. Everything +else marked *measured* was observed against a live Arcade project. + +## Setting up the Arcade project + +### One Arcade project per deployment + +Create one Arcade project for each deployment, and do not share. The verifier URL (below) is a +setting of the **project**, and a staging deployment and a production deployment have different +addresses, so they cannot both be pointed at by one project's setting. A project shared between them +would have its verifier pointing at one and failing for the other. + +### An OAuth app of your own, per provider + +**Arcade's preconfigured providers cannot be used.** Register your own OAuth app at each provider +(Google, GitHub, and so on) and give its client id and secret to Arcade as that provider's +configuration. + +This was confirmed the hard way, and the reason matters because it is not a preference. A consent +link was minted for an arbitrary `user_id` against Arcade's default Google provider. It **failed at +verification** with *"the authorization process was not completed"*. The same consent minted for an +Arcade project member's own identity **completed**. Arcade's built-in verifier has to recognise the +end user as a member of the project, so it can only serve a `user_id` that is one. Per-person +connections in OpenBot are arbitrary ids by definition — nobody is made a member of your Arcade +project in order to connect their mailbox — so a default provider cannot serve them at all. + +Arcade's admin API can create the OAuth provider, so that part can be scripted. + +### The verifier URL is the one dashboard-only step + +**The verifier URL cannot be automated.** It is set by hand in the Arcade dashboard, on the project, +and there is no API that does it. It is the single manual step in setting Arcade up, and it is the +one that is easy to do wrongly, so it is stated here rather than left to be discovered. + +Set it to this deployment's verifier address, then switch the project to a custom verifier. + +> **Do not save the custom-verifier setting without a URL.** It switches off the path that currently +> works and switches on nothing — and it presents as *this deployment's own code failing*: consents +> that used to complete stop completing, with nothing in this deployment's logs to explain it. It is +> the trap an operator will otherwise spend an afternoon on. If connections stop completing and +> nothing here changed, look at the project's verifier setting before anything in this deployment. + +## What an administrator does + +The shape is Composio's: enable an app, grant its actions to a Bot, and each person connects their +own account (or an administrator connects a Shared app's account). Two things are different and +worth reading before pressing **Add**. + +### Toolkits are curated or they are wrappers, and the difference is measured + +Every action carries a classification — whether it reads or writes, whether it is destructive — and +what OpenBot asks before running it depends on that classification. Arcade publishes it as +`metadata.behavior` on each tool, and toolkits fall into two groups: + +| Toolkit | Tools | With `metadata.behavior` | +| ----------- | ---------------------- | -----------------------: | +| `Github` | 43 | 43 | +| `Asana` | 19 | 19 | +| `GithubApi` | 100 (the page cap) | **0** | + +Curated toolkits classify every tool. Generated API wrappers classify none. An action that is not +classified is treated as a **destructive write**, because an unlabelled action is not claiming to be +safe. So an administrator enabling an unclassified toolkit is warned **before** finishing that +**every call will ask for approval** — not because anything is wrong with the toolkit, but because +nothing in it can be told apart from a write that cannot be undone. Where a curated toolkit exists +for the app, enable that one. + +### Which consent a person is being asked for + +Arcade requests consent per action, everywhere. That is the mechanism. **Whether the consent +confines anything is a different question, and it is not answered by Arcade.** It is the next +section, and it is the section most worth reading. + +## Least privilege is not an Arcade property + +Do not tell anybody that Arcade gives least-privilege access. It is true for some providers and +false for others, and the same product is on both sides of the line. + +Arcade asks for consent per action. What that consent *covers* depends on whether the provider +issues per-request scopes. Measured, through a completed consent, from a clean baseline: + +| Provider | Scopes per action | One read consent authorized | Isolation | +| -------------------------- | ---------------------------------------------------- | --------------------------- | --------- | +| Google (`Gmail`) | `gmail.readonly` vs `gmail.send` vs `gmail.compose` | **11 of 30** | yes | +| GitHub (via a GitHub App) | **none, for any action** | **43 of 43** | no | + +On Google, consenting to a read authorizes the reads that share its scope and nothing that sends. +On GitHub, consenting to `Github.CountStargazers` — **a read** — flipped all 43 actions to +authorized, **the writes and the destructive one included**, because a GitHub App's permissions are +fixed when the app is registered, and no request can ask for fewer. + +> **Least privilege is a property of (provider × how its OAuth app was registered), not of Arcade.** +> What OpenBot does is **measure which it is, per app, and say so before anybody grants anything.** + +So when somebody asks whether connecting an app "only lets it read", the answer is whatever that +app's measurement says, not what Arcade says in general. The pre-redirect consent copy names the +permission being asked for and lists the actions that share it, grouped on scopes. + +## Scope versus tool grant + +Two different questions are easily run together, and an operator will be asked both: + +- **Scope** answers *"may this deployment ever read your mail?"* It is the provider's, it is given + when somebody consents, and it stays until it is withdrawn at the provider. +- **The tool grant** answers *"may **this Bot** call `ListThreads`?"* It is an administrator's + decision, made per Bot, per action. + +**Only the second is OpenBot's to give and take instantly**, and no consent widens it. Somebody +consenting to a broad scope does not hand any Bot more actions; a Bot with no grant for an action +cannot call it however much the account allows. On a provider that cannot narrow scopes (GitHub +above), the tool grant is therefore the entire narrowing — the same position as Composio's. + +## Disconnecting one app may end another + +Arcade's connections are per **auth provider**, not per app. One Google connection serves `Gmail` +and `GoogleCalendar` both, and **no narrower instrument exists** — there is nothing to disconnect +that ends only one of them. + +So before somebody confirms a disconnect, OpenBot names the apps that genuinely share the +connection and would end with it. *Genuinely* is exact: siblings are computed on the **pair (vendor +identity, auth provider)**. Two *Shared* apps that both use Google do **not** share a connection, +because each Shared app has its own randomly minted identity at Arcade — disconnecting one does not +touch the other, and the confirmation does not claim it would. + +## Revoking at Arcade is not revoking at the provider + +Google's consent screen is reached with `include_granted_scopes=true`, so **grants accumulate on the +account**. Somebody who consents to a read and later to a write holds both at Google, as one grant. + +Disconnect deletes the Arcade connection. That ends **this deployment's** ability to act, and +OpenBot asks for the upstream withdrawal as well, but what the provider then does with the standing +grant happens out of sight. **Nobody should read Disconnect as "my Google account no longer trusts +this."** The person who wants that has to remove the app at the provider's own permissions page, and +an operator asked about it should say so rather than reassure. + +## The Re-check path, and what an unverified row means + +Composio has a one-call probe, `askAction`, that verifies a key and which Re-check runs again. +**It is Composio's own. Arcade has no counterpart in this release.** + +What an operator sees on Arcade: + +- Pressing **Re-check** **refuses** rather than spending a call. It does not guess at a read to try. +- `probe_action` and `verified_at` are **not written** for an Arcade connection. +- A row therefore carries no "last checked" date. + +**Nobody should read an unverified row as a broken connection.** Per-action state on Arcade is not +cached here and compared later: it is read **live from Arcade** — whether the person has authorized +an action is asked of Arcade when the page is drawn, so the answer on screen is Arcade's current +one. A connection that works and has never been "checked" is the normal state, not a fault. + +## What is unproven + +Nothing in this release has dialled the live Arcade API from this deployment's own code. The +measurements above were taken through completed consents against a live project; the integration +that acts on them is verified against recorded fixtures. Specifically: + +- **The `user_mismatch` error shape is assumed.** Arcade's SDK declares no such error, so what the + deployment recognises as a consent bound to the wrong user is the shape expected, not one + observed. If a consent is refused for that reason and the page shows a generic failure instead of + its own sentence, this is where to look. +- **`provider_id` is read as the auth-provider id, not the toolkit name.** That reading is inferred + from measurements and from a live refusal envelope carrying `"provider_id": "cpk-poc"`. **No + fixture has a toolkit whose provider is named differently from it**, so it has not been measured + on a Google-family toolkit — where `Gmail` and `GoogleCalendar` share one provider — and the + sibling computation above rests on it. + +## See also + +- [Composio](composio.md) — the other broker; Personal and Shared apps, approvals and audit work the + same way. +- [Architecture](../architecture.md) — where plugins, grants, policy and audit sit. +- [Configuration](../configuration.md) — the deployment's settings. From e53289cf1014f064076078367d8d77c679a3dc6c Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:44:37 +0200 Subject: [PATCH 093/232] docs(arcade): name the key, provider choice, startup refusals, admin route and verifier address Co-Authored-By: Claude Opus 5 (1M context) --- docs/plugins/arcade.md | 94 +++++++++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/docs/plugins/arcade.md b/docs/plugins/arcade.md index 114251486..0b4a616fe 100644 --- a/docs/plugins/arcade.md +++ b/docs/plugins/arcade.md @@ -11,8 +11,38 @@ page is about those, and about what to tell somebody when a connection looks wro Read [Composio](composio.md) first if you have not: what a broker is, what Personal and Shared mean, the approval rules and the audit trail are the same here and are not repeated. -Three things on this page are stated as **unproven**, and the last section lists them. Everything -else marked *measured* was observed against a live Arcade project. +Some things on this page are stated as **unproven**, and the last section lists them. Everything +else marked _measured_ was observed against a live Arcade project. + +## Who sets what, and where + +Setting it up takes three hands, and none of them can do another's: + +| Who | Does | Where | +| ---------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- | +| Whoever deploys | Sets `ARCADE_API_KEY` | The deployment's environment. There is no screen for it | +| Whoever deploys | Sets `OPENBOT_BROKER_PROVIDER`, only where both keys are set | The deployment's environment | +| Whoever deploys | Registers an OAuth app per provider, and sets the verifier | Arcade's dashboard, below | +| An administrator | Enables an app and grants its actions to a Bot | `/admin/plugins/arcade`, then the app's own page, as for Composio | +| Each person | Connects their own account to that app | `/settings/connected-accounts` | + +The key is an environment variable and nothing else, as Composio's is. No administrator can turn +Arcade on from a page; `/admin/plugins/arcade` is where a key that is already set gets used. + +### Choosing the provider + +A deployment runs one broker at a time. `OPENBOT_BROKER_PROVIDER` takes `composio` or `arcade`, and +anything else stops the server at startup. + +**Where exactly one of the two keys is set and the variable is unset, that provider is the active +one, so no existing deployment has to set anything.** The variable matters only when both keys are +set. Setting both is legal and expected, because ending an account at the old vendor needs that +vendor's key. With both set and the variable unset, neither is active. + +Two refusals protect this, and both are deliberate: + +- **A named provider whose key is not set stops the server at startup.** An operator sees: _OPENBOT_BROKER_PROVIDER=arcade names a provider whose ARCADE_API_KEY is not set. Set ARCADE_API_KEY, or change or unset OPENBOT_BROKER_PROVIDER._ It does not fall back to a deployment with no broker, because that would send somebody looking for a broken vendor instead of at their own environment. +- **Activating a provider is refused while the other one still holds connections.** It is keyed on connection rows, not on keys. The message names the provider being activated, how many connections still belong to the other, and tells the operator to keep both keys set and end those connections through the other provider's sunset flow first. Switching by editing the environment would leave accounts nobody can revoke. ## Setting up the Arcade project @@ -31,7 +61,7 @@ configuration. This was confirmed the hard way, and the reason matters because it is not a preference. A consent link was minted for an arbitrary `user_id` against Arcade's default Google provider. It **failed at -verification** with *"the authorization process was not completed"*. The same consent minted for an +verification** with _"the authorization process was not completed"_. The same consent minted for an Arcade project member's own identity **completed**. Arcade's built-in verifier has to recognise the end user as a member of the project, so it can only serve a `user_id` that is one. Per-person connections in OpenBot are arbitrary ids by definition — nobody is made a member of your Arcade @@ -45,10 +75,19 @@ Arcade's admin API can create the OAuth provider, so that part can be scripted. and there is no API that does it. It is the single manual step in setting Arcade up, and it is the one that is easy to do wrongly, so it is stated here rather than left to be discovered. -Set it to this deployment's verifier address, then switch the project to a custom verifier. +Set it to this deployment's verifier address, then switch the project to a custom verifier. The +address is this API's public origin — `OPENBOT_PUBLIC_URL`, which defaults to `BETTER_AUTH_URL` — +followed by the path, so paste: + +``` +/api/plugins/arcade/verify +``` + +It is the API's origin, not the browser app's, where the two differ. A browser that arrives there +is sent on to the connected-accounts page, with a reason beside it if the consent was refused. > **Do not save the custom-verifier setting without a URL.** It switches off the path that currently -> works and switches on nothing — and it presents as *this deployment's own code failing*: consents +> works and switches on nothing — and it presents as _this deployment's own code failing_: consents > that used to complete stop completing, with nothing in this deployment's logs to explain it. It is > the trap an operator will otherwise spend an afternoon on. If connections stop completing and > nothing here changed, look at the project's verifier setting before anything in this deployment. @@ -65,11 +104,11 @@ Every action carries a classification — whether it reads or writes, whether it what OpenBot asks before running it depends on that classification. Arcade publishes it as `metadata.behavior` on each tool, and toolkits fall into two groups: -| Toolkit | Tools | With `metadata.behavior` | -| ----------- | ---------------------- | -----------------------: | -| `Github` | 43 | 43 | -| `Asana` | 19 | 19 | -| `GithubApi` | 100 (the page cap) | **0** | +| Toolkit | Tools | With `metadata.behavior` | +| ----------- | ------------------ | -----------------------: | +| `Github` | 43 | 43 | +| `Asana` | 19 | 19 | +| `GithubApi` | 100 (the page cap) | **0** | Curated toolkits classify every tool. Generated API wrappers classify none. An action that is not classified is treated as a **destructive write**, because an unlabelled action is not claiming to be @@ -78,24 +117,18 @@ safe. So an administrator enabling an unclassified toolkit is warned **before** nothing in it can be told apart from a write that cannot be undone. Where a curated toolkit exists for the app, enable that one. -### Which consent a person is being asked for - -Arcade requests consent per action, everywhere. That is the mechanism. **Whether the consent -confines anything is a different question, and it is not answered by Arcade.** It is the next -section, and it is the section most worth reading. - ## Least privilege is not an Arcade property Do not tell anybody that Arcade gives least-privilege access. It is true for some providers and false for others, and the same product is on both sides of the line. -Arcade asks for consent per action. What that consent *covers* depends on whether the provider +Arcade asks for consent per action. What that consent _covers_ depends on whether the provider issues per-request scopes. Measured, through a completed consent, from a clean baseline: -| Provider | Scopes per action | One read consent authorized | Isolation | -| -------------------------- | ---------------------------------------------------- | --------------------------- | --------- | -| Google (`Gmail`) | `gmail.readonly` vs `gmail.send` vs `gmail.compose` | **11 of 30** | yes | -| GitHub (via a GitHub App) | **none, for any action** | **43 of 43** | no | +| Provider | Scopes per action | One read consent authorized | Isolation | +| ------------------------- | --------------------------------------------------- | --------------------------- | --------- | +| Google (`Gmail`) | `gmail.readonly` vs `gmail.send` vs `gmail.compose` | **11 of 30** | yes | +| GitHub (via a GitHub App) | **none, for any action** | **43 of 43** | no | On Google, consenting to a read authorizes the reads that share its scope and nothing that sends. On GitHub, consenting to `Github.CountStargazers` — **a read** — flipped all 43 actions to @@ -113,9 +146,9 @@ permission being asked for and lists the actions that share it, grouped on scope Two different questions are easily run together, and an operator will be asked both: -- **Scope** answers *"may this deployment ever read your mail?"* It is the provider's, it is given +- **Scope** answers _"may this deployment ever read your mail?"_ It is the provider's, it is given when somebody consents, and it stays until it is withdrawn at the provider. -- **The tool grant** answers *"may **this Bot** call `ListThreads`?"* It is an administrator's +- **The tool grant** answers _"may **this Bot** call `ListThreads`?"_ It is an administrator's decision, made per Bot, per action. **Only the second is OpenBot's to give and take instantly**, and no consent widens it. Somebody @@ -130,8 +163,8 @@ and `GoogleCalendar` both, and **no narrower instrument exists** — there is no that ends only one of them. So before somebody confirms a disconnect, OpenBot names the apps that genuinely share the -connection and would end with it. *Genuinely* is exact: siblings are computed on the **pair (vendor -identity, auth provider)**. Two *Shared* apps that both use Google do **not** share a connection, +connection and would end with it. _Genuinely_ is exact: siblings are computed on the **pair (vendor +identity, auth provider)**. Two _Shared_ apps that both use Google do **not** share a connection, because each Shared app has its own randomly minted identity at Arcade — disconnecting one does not touch the other, and the confirmation does not claim it would. @@ -143,8 +176,8 @@ account**. Somebody who consents to a read and later to a write holds both at Go Disconnect deletes the Arcade connection. That ends **this deployment's** ability to act, and OpenBot asks for the upstream withdrawal as well, but what the provider then does with the standing grant happens out of sight. **Nobody should read Disconnect as "my Google account no longer trusts -this."** The person who wants that has to remove the app at the provider's own permissions page, and -an operator asked about it should say so rather than reassure. +this."** The person who wants that has to withdraw the grant at the provider itself, and an operator asked +about it should say so rather than reassure. ## The Re-check path, and what an unverified row means @@ -169,14 +202,17 @@ measurements above were taken through completed consents against a live project; that acts on them is verified against recorded fixtures. Specifically: - **The `user_mismatch` error shape is assumed.** Arcade's SDK declares no such error, so what the - deployment recognises as a consent bound to the wrong user is the shape expected, not one - observed. If a consent is refused for that reason and the page shows a generic failure instead of + deployment recognises as `user_mismatch` — the vendor's answer when the returning consent does + not belong to the identity the attempt was started for — is the shape expected, not one observed. If a consent is refused for that reason and the page shows a generic failure instead of its own sentence, this is where to look. - **`provider_id` is read as the auth-provider id, not the toolkit name.** That reading is inferred from measurements and from a live refusal envelope carrying `"provider_id": "cpk-poc"`. **No fixture has a toolkit whose provider is named differently from it**, so it has not been measured on a Google-family toolkit — where `Gmail` and `GoogleCalendar` share one provider — and the sibling computation above rests on it. +- **The wording of Arcade's dashboard is not described here.** This page says to set the project's + verifier to the address above and does not name the dashboard's fields or screens, which were not + checked against it and may change. The address itself is this deployment's. ## See also From bdd6d07d5248e6f6e9f17ccb554f15a8e09bd858 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:44:46 +0200 Subject: [PATCH 094/232] feat(arcade): the connected-account page lists grants by GrantState, grouped by permission, and shows the consent copy before redirecting Each action renders by its GrantState: Authorize only for needs-consent; needs-operator names the missing secret and says an administrator sets it, with no Authorize; unknown offers Retry. The route's synthetic toolkit-named unknown (a grantsFor throw) renders as "could not read", never as an action. Actions are grouped on the permission they require (an all-or-nothing app is one group). A Shared app shows state to everyone and Authorize only to administrators. Authorize first renders consentCopy's sentence, built from the grants already fetched, and only then redirects: the copy 11.2 wrote now has a call site. Ordering drops to authorized-then-search. "Recently refused" is not sourced: no route this page can call reads a person's mcp.call_rejected rows per app, and no column was invented for it. The list is drawn for Arcade only, so Composio renders are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/plugins/grant-list.tsx | 281 ++++++++++ app/src/lib/plugins/grants.ts | 159 ++++++ .../settings/connected-accounts/$key.tsx | 38 ++ app/tests/connected-account-grants.test.tsx | 525 ++++++++++++++++++ 4 files changed, 1003 insertions(+) create mode 100644 app/src/components/plugins/grant-list.tsx create mode 100644 app/src/lib/plugins/grants.ts create mode 100644 app/tests/connected-account-grants.test.tsx diff --git a/app/src/components/plugins/grant-list.tsx b/app/src/components/plugins/grant-list.tsx new file mode 100644 index 000000000..7ded46b32 --- /dev/null +++ b/app/src/components/plugins/grant-list.tsx @@ -0,0 +1,281 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { PageRows, PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { consentCopy } from "@/lib/plugins/enablement-notes"; +import { + authorizeActionMutationOptions, + consentActionsFrom, + couldNotRead, + type GrantGroup, + type GrantState, + grantsQueryOptions, + groupGrants, +} from "@/lib/plugins/grants"; + +/** + * What one account holds at one app, action by action, grouped by the permission each action needs. + * + * ONE ROW PER ACTION, DRAWN FROM ITS `GrantState` AND NOTHING ELSE. Authorize is offered for + * `needs-consent` only: a `needs-operator` action is blocked by a deployment secret that no amount + * of consenting moves, and an `unknown` one offers a Retry, because not knowing is not the same as + * not connected and telling somebody to authorize what they already authorized sends them round + * twice. + * + * NO ORDERING BY "RECENTLY REFUSED". Authorized actions come first and everything else sits behind + * the search; there is no per-person, per-app refusal read on any route this page can call, and + * inventing a column to order on would be a claim this page could not back. + */ +export function GrantList({ + serverId, + appNames, + canAuthorize, + landedAction, + returnTo, +}: { + serverId: string; + /** Every name this app goes by, so the route's synthetic toolkit-named entry is recognised. */ + appNames: readonly string[]; + /** Whether this viewer may mint a consent. State is shown to everyone; minting is not. */ + canAuthorize: boolean; + landedAction: string | null; + returnTo: "settings" | "admin"; +}) { + const grants = useQuery(grantsQueryOptions(serverId, true)); + const [search, setSearch] = useState(""); + const [choosing, setChoosing] = useState(null); + const [notice, setNotice] = useState(null); + const authorize = useMutation({ + ...authorizeActionMutationOptions(returnTo), + onError: (thrown: Error) => setNotice(thrown.message), + /* A full page navigation: the consent screen is the vendor's own and shown in this browser. */ + onSuccess: (authorizationUrl) => { + if (authorizationUrl === null) { + setNotice( + "This deployment answered without a consent link, so there was nowhere to send you and nothing was authorized. Try again, and tell an administrator if it persists.", + ); + return; + } + window.location.href = authorizationUrl; + }, + }); + + if (grants.isPending) return null; + if (grants.error) { + return ( + +

+ Could not read what this account holds just now, so nothing is said + about it either way.{" "} + +

+
+ ); + } + + const all = grants.data ?? []; + if (couldNotRead(all, appNames)) { + const [only] = all; + return ( + + {/* Not an action: the entry's label is the toolkit's own name, which a person must not + see in their grant list as if it were one. */} +

+ Could not read what this account holds just now, so nothing is said + about it either way. + {only?.state === "unknown" ? ` ${only.reason}` : null}{" "} + +

+
+ ); + } + + const term = search.trim().toLowerCase(); + const visible = (grant: GrantState) => + term.length > 0 + ? grant.action.toLowerCase().includes(term) + : grant.state === "authorized"; + const hidden = term.length > 0 ? 0 : all.filter((g) => !visible(g)).length; + const groups: GrantGroup[] = groupGrants(all.filter(visible)); + const consentActions = consentActionsFrom(all); + + return ( + + {notice ? ( +

+ {notice} +

+ ) : null} + setSearch(event.target.value)} + placeholder="Search actions" + type="search" + value={search} + /> + {groups.map((group) => ( +
+

+ {group.kind === "whole-app" + ? "Everything in this app, approved together" + : group.kind === "none" + ? "Asks for no permission of its own" + : `Needs ${group.scopes.join(", ")}`} +

+ + {group.grants.map((grant) => ( + + + {grant.action} + + {describe(grant, canAuthorize)} + + + + {grant.state === "needs-consent" && canAuthorize ? ( + + ) : null} + {grant.state === "unknown" ? ( + + ) : null} + + + ))} + +
+ ))} + {hidden > 0 ? ( +

+ {hidden} more {hidden === 1 ? "action" : "actions"} not yet + authorized. Search to find {hidden === 1 ? "it" : "one"}. +

+ ) : null} + {term.length > 0 && groups.length === 0 ? ( +

+ No action matches that search. +

+ ) : null} + {choosing !== null ? ( + setChoosing(null)} + onContinue={() => authorize.mutate({ serverId, action: choosing })} + sentence={consentCopy(choosing, consentActions).sentence} + /> + ) : null} +
+ ); +} + +/** + * What somebody reads immediately before being sent to a consent screen. The sentence is + * `consentCopy`'s and is rendered as it came, never reworded here. + */ +function ConsentNotice({ + action, + busy, + sentence, + onCancel, + onContinue, +}: { + action: string; + busy: boolean; + sentence: string; + onCancel: () => void; + onContinue: () => void; +}) { + return ( +
+

{sentence}

+
+ + +
+
+ ); +} + +function describe(grant: GrantState, canAuthorize: boolean): string { + switch (grant.state) { + case "authorized": + return "Authorized."; + case "needs-consent": + return canAuthorize + ? "Not authorized yet." + : "Not authorized yet. An administrator can authorize it."; + case "needs-operator": + return grant.missing.length > 0 + ? `Missing ${grant.missing.join(", ")}. An administrator sets it on this deployment; authorizing cannot fix it.` + : "A setting on this deployment is missing. An administrator sets it; authorizing cannot fix it."; + case "unknown": + return `Could not read this one just now. ${grant.reason}`; + } +} diff --git a/app/src/lib/plugins/grants.ts b/app/src/lib/plugins/grants.ts new file mode 100644 index 000000000..9637f134a --- /dev/null +++ b/app/src/lib/plugins/grants.ts @@ -0,0 +1,159 @@ +import { mutationOptions, queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import type { ConsentAction } from "./enablement-notes"; + +/** + * What one account holds at one app, action by action, as `GET /servers/:id/grants` answers it. + * + * The same four states the server's `GrantState` names. Declared here rather than imported because + * `app/` never imports from `server/`; the route's own docblock is the contract. + */ +export type GrantState = + | { action: string; state: "authorized"; scopes: string[] } + | { action: string; state: "needs-consent"; scopes: string[] } + | { action: string; state: "needs-operator"; missing: string[] } + | { action: string; state: "unknown"; reason: string }; + +export const grantKeys = { + forServer: (serverId: string) => ["plugins", "grants", serverId] as const, +}; + +export function grantsQueryOptions(serverId: string, enabled: boolean) { + return queryOptions({ + queryKey: grantKeys.forServer(serverId), + enabled, + queryFn: async (): Promise => { + const response = await client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/grants`, + { fallback: "What this account holds could not be read." }, + ); + const body: { actions?: unknown } = await response.json(); + return Array.isArray(body.actions) ? (body.actions as GrantState[]) : []; + }, + }); +} + +/** + * Begin authorizing ONE action. Answers the vendor's consent url, which the caller navigates to. + * + * `null` rather than `undefined` for the reason spelled out over `connectAccountMutationOptions`. + */ +export function authorizeActionMutationOptions( + returnTo: "settings" | "admin" = "settings", +) { + return mutationOptions({ + mutationFn: async (variables: { + serverId: string; + action: string; + }): Promise => { + const authorizationUrl = await client( + `/api/plugins/servers/${encodeURIComponent(variables.serverId)}/authorize?returnTo=${returnTo}`, + "authorizationUrl", + { + method: "POST", + body: { action: variables.action }, + fallback: "That action could not be authorized.", + }, + ); + return authorizationUrl ?? null; + }, + }); +} + +/** + * Whether this answer is the route's synthetic "could not read". + * + * WHEN `grantsFor` THROWS, THE ROUTE ANSWERS 200 WITH ONE `unknown` ENTRY WHOSE `action` IS THE + * TOOLKIT NAME: a throw yields no action names to enumerate, so that label is not an action and must + * never be drawn as one. Recognised as a sole `unknown` entry that is either the app's own name or + * not shaped like an action at all (an Arcade action is dotted, `Toolkit.Action`; a toolkit is not). + */ +export function couldNotRead( + grants: readonly GrantState[], + appNames: readonly string[], +): boolean { + if (grants.length !== 1) return false; + const [only] = grants; + if (only?.state !== "unknown") return false; + const label = only.action.toLowerCase(); + return ( + appNames.some((name) => name.toLowerCase() === label) || + !only.action.includes(".") + ); +} + +/** The actions the consent copy reasons about: those that name a permission or could be asked for. */ +export function consentActionsFrom( + grants: readonly GrantState[], +): ConsentAction[] { + const actions: ConsentAction[] = []; + for (const grant of grants) { + if (grant.state === "authorized" || grant.state === "needs-consent") { + actions.push({ name: grant.action, scopes: grant.scopes }); + } + } + return actions; +} + +export type GrantGroup = { + /** The permissions every action in the group requires; empty for the whole-app and no-permission groups. */ + scopes: string[]; + /** "whole-app" when no action in the app names a permission; "none" for actions that name none. */ + kind: "scoped" | "whole-app" | "none"; + grants: GrantState[]; +}; + +function scopesOf(grant: GrantState): string[] { + return grant.state === "authorized" || grant.state === "needs-consent" + ? [...new Set(grant.scopes)].sort() + : []; +} + +const RANK: Record = { + authorized: 0, + "needs-consent": 1, + "needs-operator": 2, + unknown: 3, +}; + +/** + * The grant list grouped by the permission each action requires, so what is drawn after a consent + * matches what the consent screen said. + * + * AN ALL-OR-NOTHING APP IS ONE GROUP CONTAINING EVERYTHING: when no action names a permission there + * is nothing to group on, and one group is the honest rendering rather than a degraded one. Names + * are compared exactly and never case-folded: Arcade's are case-sensitive. + */ +export function groupGrants(grants: readonly GrantState[]): GrantGroup[] { + const anyScoped = grants.some((grant) => scopesOf(grant).length > 0); + const ordered = [...grants].sort( + (left, right) => + RANK[left.state] - RANK[right.state] || + left.action.localeCompare(right.action), + ); + if (!anyScoped) { + return ordered.length > 0 + ? [{ scopes: [], kind: "whole-app", grants: ordered }] + : []; + } + const groups = new Map(); + for (const grant of ordered) { + const scopes = scopesOf(grant); + const key = scopes.join("\u0000"); + let group = groups.get(key); + if (!group) { + group = { + scopes, + kind: scopes.length > 0 ? "scoped" : "none", + grants: [], + }; + groups.set(key, group); + } + group.grants.push(grant); + } + return [...groups.values()].sort( + (left, right) => + Number(left.kind === "none") - Number(right.kind === "none") || + left.scopes.join(",").localeCompare(right.scopes.join(",")), + ); +} diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 25b5eeef3..49adb9f3b 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -12,6 +12,7 @@ import { BrokeredAccountRow, useBrokeredAccount, } from "@/components/plugins/brokered-account-row"; +import { GrantList } from "@/components/plugins/grant-list"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -27,6 +28,7 @@ import { ItemTitle, } from "@/components/ui/item"; import { Separator } from "@/components/ui/separator"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; import { connectionSiblingsQueryOptions, @@ -180,6 +182,21 @@ function RouteComponent() { ? disconnectSiblingsNotice(siblings.data) : null; const landedAction = landedActionFrom(action); + /* + * THE GRANT LIST IS DRAWN FOR ARCADE ONLY. It is per-action and Arcade is the broker that grants + * per action; Composio's auth config fixes one state for the whole toolkit, its route answers the + * same degenerate entry N times, and the authorize route refuses it with 503. Gating on the NAME + * is what keeps every Composio render byte-identical. + */ + const perActionGrants = provider === "arcade"; + /* Who is asking only matters to a Shared app's Authorize, so it is not read for anything else. */ + const sharedHere = server?.accountMode === "shared"; + const me = useQuery({ + ...currentUserQueryOptions(), + enabled: perActionGrants && sharedHere, + }).data; + const isAdmin = me?.role === "admin"; + const appNames = [key, server?.title ?? key]; /* Everything the brokered row below reads and does. See `brokered-account-row.tsx`. */ const brokeredAccount = useBrokeredAccount({ @@ -302,6 +319,17 @@ function RouteComponent() {
+ + {/* State is shown to everyone; minting a consent is the administrator's act. */} + {perActionGrants ? ( + + ) : null} ); } @@ -361,6 +389,16 @@ function RouteComponent() { /> + + {perActionGrants ? ( + + ) : null} ); } diff --git a/app/tests/connected-account-grants.test.tsx b/app/tests/connected-account-grants.test.tsx new file mode 100644 index 000000000..9d93cf1d2 --- /dev/null +++ b/app/tests/connected-account-grants.test.tsx @@ -0,0 +1,525 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { consentCopy } from "@/lib/plugins/enablement-notes"; +import type { GrantState } from "@/lib/plugins/grants"; +import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; +import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; + +/** + * THE GRANT LIST on an app's own page, drawn by `GrantState`, grouped by permission, with the consent + * copy rendered before any redirect. + * + * Every assertion is on RENDERED markup: a sentence computed and never drawn is not shipped. + */ +/** + * (harness below is shared in shape with connected-account-confirmation.test.tsx) + * + * Three things are pinned, and the first two are RENDERED markup because a sentence computed and + * never drawn is not shipped: the sibling list names what a Disconnect would also end and draws + * NOTHING when there is none; an Arcade deployment's page never says Composio, in text or in + * attributes; and a Composio deployment's three vendor sentences are character-for-character what + * they were. The fourth is the landing parameter the verifier's redirect carries. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + // Let React's scheduler drain before the DOM globals it reads go away. + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); + +const APP = "gmail"; + +function captureRouteState(route: object): Record { + return { ...route, options: { ...(route as { options: object }).options } }; +} +function restoreRouteState( + route: object, + snapshot: Record, +): void { + for (const key of Object.keys(route)) { + if (!(key in snapshot)) { + delete (route as Record)[key]; + } + } + Object.assign(route, snapshot); +} +const pristine = captureRouteState(ConnectedAccountRoute); +let snapshot: Record; +beforeEach(() => { + snapshot = captureRouteState(pristine); +}); +afterEach(() => { + restoreRouteState(ConnectedAccountRoute, snapshot); +}); + +type Provider = "composio" | "arcade"; + +function server( + provider: Provider, + accountMode: "shared" | null = null, +): PluginServer { + return { + authScheme: "OAUTH2", + id: APP, + title: "Gmail", + vendor: "Google", + url: provider === "arcade" ? "arcade://gmail" : "https://example.invalid", + summary: "Mail.", + docsUrl: "", + provenance: provider, + hasCredential: false, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + accountMode, + sharedVendorConnected: false, + tools: [], + withdrawn: [], + }; +} + +type Setup = { + provider: Provider; + connected: boolean; + configured?: boolean; + siblings?: { siblings: string[]; unanswered: number }; + /** Whether the siblings read answers 500. */ + siblingsFail?: boolean; + grants?: GrantState[]; + /** Whether the grants read answers 500. */ + accountMode?: "shared" | null; + role?: "admin" | "user"; +}; + +const authorizeCalls: string[] = []; + +const siblingReads: string[] = []; + +function install(setup: Setup) { + siblingReads.length = 0; + const page: PluginsPage = { + catalogue: [], + servers: [server(setup.provider, setup.accountMode ?? null)], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured: setup.configured ?? true, + provider: setup.provider, + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); + authorizeCalls.length = 0; + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input).split("?")[0] ?? ""; + if (path.endsWith("/grants")) return json({ actions: setup.grants ?? [] }); + if (path.endsWith("/authorize")) { + authorizeCalls.push(String(init?.body)); + return json({ authorizationUrl: "https://vendor.invalid/consent" }); + } + if (path === "/api/me") + return json({ + user: { id: "u", role: setup.role ?? "user", email: "u@x.io" }, + }); + if (path === "/api/plugins") return json(page); + if (path.startsWith("/api/plugins/connections")) { + return json({ + connections: setup.connected + ? [ + { + serverId: APP, + scope: "", + connectedAt: "2026-09-10T00:00:00.000Z", + verified: true, + verifiedAt: null, + checkable: false, + }, + ] + : [], + redirectUri: null, + }); + } + if (path.endsWith("/connection/siblings")) { + siblingReads.push(path); + if (setup.siblingsFail) return json({ error: "no" }, 500); + return json(setup.siblings ?? { siblings: [], unanswered: 0 }); + } + if (path.endsWith("/connection/confirm")) + return json({ connected: setup.connected }); + return json({ error: "unexpected" }, 404); + }) as typeof fetch; +} + +function renderScreen(search = "") { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const settingsRoute = createRoute({ + path: "/settings", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/connected-accounts/", + getParentRoute: () => settingsRoute, + component: () => null, + }); + const wired = ( + ConnectedAccountRoute as unknown as { + update: (options: unknown) => typeof ConnectedAccountRoute; + } + ).update({ + id: "/connected-accounts/$key", + path: "/connected-accounts/$key", + getParentRoute: () => settingsRoute, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([settingsRoute.addChildren([indexRoute, wired])]), + ]), + history: createMemoryHistory({ + initialEntries: [`/settings/connected-accounts/${APP}${search}`], + }), + }); + return render( + + + , + ); +} + +/** Every string the page puts in front of anyone: its text AND its markup, attributes included. */ +function everythingRendered(view: ReturnType): string { + return `${view.container.textContent ?? ""}\n${view.container.innerHTML}`; +} + +const GMAIL: GrantState[] = [ + { + action: "Gmail.ListEmails", + state: "authorized", + scopes: ["gmail.readonly"], + }, + { + action: "Gmail.ListThreads", + state: "authorized", + scopes: ["gmail.readonly"], + }, + { action: "Gmail.SendEmail", state: "needs-consent", scopes: ["gmail.send"] }, + { + action: "Gmail.Setup", + state: "needs-operator", + missing: ["GOOGLE_CLIENT_SECRET"], + }, + { action: "Gmail.Flaky", state: "unknown", reason: "Try again in a moment." }, +]; + +function row(view: ReturnType, action: string): HTMLElement { + const found = view.container.querySelector( + `[data-action="${action}"]`, + ); + if (!found) throw new Error(`no row for ${action}`); + return found; +} + +async function showAll(view: ReturnType) { + await view.findByLabelText("Search actions"); + await userEvent.type(view.getByLabelText("Search actions"), "Gmail."); +} + +// ---- 1: each action renders by its GrantState ---- + +test("Authorize appears for needs-consent and for nothing else", async () => { + install({ provider: "arcade", connected: true, grants: GMAIL }); + const view = renderScreen(); + await showAll(view); + await waitFor(() => row(view, "Gmail.SendEmail")); + const buttons = (action: string) => + [...row(view, action).querySelectorAll("button")].map((b) => b.textContent); + expect(buttons("Gmail.SendEmail")).toEqual(["Authorize"]); + expect(buttons("Gmail.ListEmails")).toEqual([]); + expect(buttons("Gmail.Setup")).toEqual([]); + expect(buttons("Gmail.Flaky")).not.toContain("Authorize"); +}); + +test("needs-operator names the missing secret, says an administrator sets it, and offers no Authorize", async () => { + install({ + provider: "arcade", + connected: true, + grants: [ + { + action: "Gmail.Setup", + state: "needs-operator", + missing: ["GOOGLE_CLIENT_SECRET"], + }, + ], + }); + const view = renderScreen(); + await showAll(view); + await waitFor(() => row(view, "Gmail.Setup")); + const text = row(view, "Gmail.Setup").textContent ?? ""; + expect(text).toContain("GOOGLE_CLIENT_SECRET"); + expect(text).toContain("An administrator sets it"); + expect(row(view, "Gmail.Setup").querySelector("button")).toBeNull(); + expect(view.queryByText("Authorize")).toBeNull(); +}); + +test("unknown offers Retry and never a speculative Authorize", async () => { + install({ + provider: "arcade", + connected: true, + grants: [ + { + action: "Gmail.Flaky", + state: "unknown", + reason: "Try again in a moment.", + }, + { + action: "Gmail.ListEmails", + state: "authorized", + scopes: ["gmail.readonly"], + }, + ], + }); + const view = renderScreen(); + await showAll(view); + await waitFor(() => row(view, "Gmail.Flaky")); + const labels = [...row(view, "Gmail.Flaky").querySelectorAll("button")].map( + (b) => b.textContent, + ); + expect(labels).toEqual(["Retry"]); + expect(view.queryByText("Authorize")).toBeNull(); +}); + +test("the route's synthetic toolkit-named unknown reads as could not read and never as an action", async () => { + install({ + provider: "arcade", + connected: true, + grants: [ + { + action: "Gmail", + state: "unknown", + reason: + "This deployment could not read what this account holds just now, so nothing is said about it either way. Try again in a moment.", + }, + ], + }); + const view = renderScreen(); + const notice = await view.findByRole("status"); + expect(notice.textContent?.toLowerCase()).toContain("could not read"); + expect(view.container.querySelector("[data-action]")).toBeNull(); + expect(view.container.querySelector('[data-action="Gmail"]')).toBeNull(); + expect( + [...view.container.querySelectorAll("button")].map((b) => b.textContent), + ).toContain("Retry"); +}); + +// ---- 2: grouped by permission ---- + +test("actions are grouped by the permission they require", async () => { + install({ provider: "arcade", connected: true, grants: GMAIL }); + const view = renderScreen(); + await showAll(view); + await waitFor(() => row(view, "Gmail.SendEmail")); + const group = (scope: string) => + view.container.querySelector(`[data-grant-group="${scope}"]`); + const readonly = group("gmail.readonly"); + expect( + readonly?.querySelector('[data-action="Gmail.ListEmails"]'), + ).not.toBeNull(); + expect( + readonly?.querySelector('[data-action="Gmail.ListThreads"]'), + ).not.toBeNull(); + expect(readonly?.querySelector('[data-action="Gmail.SendEmail"]')).toBeNull(); + expect( + group("gmail.send")?.querySelector('[data-action="Gmail.SendEmail"]'), + ).not.toBeNull(); +}); + +test("an all-or-nothing app renders as ONE group containing everything", async () => { + install({ + provider: "arcade", + connected: true, + grants: [ + { action: "Github.A", state: "authorized", scopes: [] }, + { action: "Github.B", state: "needs-consent", scopes: [] }, + { action: "Github.C", state: "authorized", scopes: [] }, + ], + }); + const view = renderScreen(); + await userEvent.type(await view.findByLabelText("Search actions"), "Github."); + await waitFor(() => row(view, "Github.B")); + const groups = view.container.querySelectorAll("[data-grant-group]"); + expect(groups.length).toBe(1); + expect(groups[0]?.querySelectorAll("[data-action]").length).toBe(3); + expect(groups[0]?.getAttribute("data-grant-group")).toBe("whole-app"); +}); + +// ---- 3: ordering ---- + +test("authorized actions are listed first and the rest wait behind a search", async () => { + install({ provider: "arcade", connected: true, grants: GMAIL }); + const view = renderScreen(); + await view.findByText(/more actions not yet authorized/); + expect(row(view, "Gmail.ListEmails")).toBeTruthy(); + expect( + view.container.querySelector('[data-action="Gmail.SendEmail"]'), + ).toBeNull(); + expect(view.container.textContent).toContain( + "3 more actions not yet authorized", + ); + await userEvent.type(view.getByLabelText("Search actions"), "send"); + await waitFor(() => row(view, "Gmail.SendEmail")); + expect( + view.container.querySelector('[data-action="Gmail.ListEmails"]'), + ).toBeNull(); +}); + +// ---- 4: Shared ---- + +test("a Shared app shows state to a member but Authorize only to an administrator", async () => { + install({ + provider: "arcade", + connected: false, + accountMode: "shared", + role: "user", + grants: GMAIL, + }); + const member = renderScreen(); + await showAll(member); + await waitFor(() => row(member, "Gmail.SendEmail")); + expect(row(member, "Gmail.ListEmails").textContent).toContain("Authorized"); + expect(member.queryByText("Authorize")).toBeNull(); + cleanup(); + + install({ + provider: "arcade", + connected: false, + accountMode: "shared", + role: "admin", + grants: GMAIL, + }); + const admin = renderScreen(); + await showAll(admin); + await waitFor(() => row(admin, "Gmail.SendEmail")); + await waitFor(() => expect(admin.queryByText("Authorize")).not.toBeNull()); +}); + +// ---- Part B: the consent copy is RENDERED before the redirect ---- + +test("pressing Authorize renders consentCopy's sentence and redirects nowhere yet", async () => { + install({ provider: "arcade", connected: true, grants: GMAIL }); + const view = renderScreen(); + await showAll(view); + await waitFor(() => row(view, "Gmail.SendEmail")); + fireEvent.click(view.getByText("Authorize")); + const expected = consentCopy("Gmail.SendEmail", [ + { name: "Gmail.ListEmails", scopes: ["gmail.readonly"] }, + { name: "Gmail.ListThreads", scopes: ["gmail.readonly"] }, + { name: "Gmail.SendEmail", scopes: ["gmail.send"] }, + ]).sentence; + const panel = await view.findByRole("group", { + name: /Before you authorize/, + }); + expect(panel.textContent).toContain(expected); + expect(panel.textContent).toContain( + "Approving Gmail.SendEmail asks the provider for the permission gmail.send.", + ); + // Nothing was minted by merely looking at the copy. + expect(authorizeCalls).toEqual([]); + fireEvent.click(view.getByText("Continue to consent")); + await waitFor(() => + expect(authorizeCalls).toEqual([ + JSON.stringify({ action: "Gmail.SendEmail" }), + ]), + ); +}); + +test("the consent copy never promises that only the named action becomes usable", async () => { + install({ + provider: "arcade", + connected: true, + grants: [ + { + action: "Github.CountStargazers", + state: "needs-consent", + scopes: ["repo"], + }, + { + action: "Github.CreateIssue", + state: "needs-consent", + scopes: ["repo"], + }, + ], + }); + const view = renderScreen(); + await userEvent.type(await view.findByLabelText("Search actions"), "Github."); + await waitFor(() => row(view, "Github.CountStargazers")); + fireEvent.click( + row(view, "Github.CountStargazers").querySelector("button") as HTMLElement, + ); + const panel = await view.findByRole("group", { + name: /Before you authorize/, + }); + expect(panel.textContent).toContain( + "It also enables this other action requiring it: Github.CreateIssue.", + ); + expect(panel.textContent?.toLowerCase()).not.toMatch( + /only (this|that|the named)|just this/, + ); +}); + +// ---- 12.1's rule, on the new markup ---- + +test("on Arcade the grant list says nothing of Composio, in text or attributes", async () => { + install({ + provider: "arcade", + connected: true, + grants: GMAIL, + siblings: { siblings: ["googledocs"], unanswered: 0 }, + }); + const view = renderScreen("?action=Gmail.ListEmails"); + await showAll(view); + await waitFor(() => row(view, "Gmail.SendEmail")); + fireEvent.click(view.getByText("Authorize")); + await view.findByRole("group", { name: /Before you authorize/ }); + expect(everythingRendered(view)).not.toMatch(/composio/i); +}); + +test("a Composio deployment draws no grant list and never reads the grants", async () => { + install({ provider: "composio", connected: true, grants: GMAIL }); + const view = renderScreen(); + await view.findByText("Disconnect"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(view.container.querySelector("[data-action]")).toBeNull(); + expect(view.queryByLabelText("Search actions")).toBeNull(); +}); From 465570d6132543dabae6d4b29276e7abc48de28d Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 12 Oct 2026 17:47:32 +0200 Subject: [PATCH 095/232] test(arcade): BROKERED_PROFILE's exhaustive expectation carries envVar 898ceee5 added `envVar` to BROKERED_PROFILE so the directory sentences could name the active broker's key. store-scheme-sites.test.ts asserts that record with an exhaustive toEqual, and was not in that commit's targeted test run, so it has been red on the branch since: @@ -1,2 +1,3 @@ { + "envVar": "COMPOSIO_API_KEY", "idPrefix": "composio-", This is the `server/tests`-is-outside-tsc hazard exactly: the source change was type-safe, typecheck stayed green, and only running the file could show it. Found by a sharded full-suite sweep (4625 pass / 1 fail), not by any gate. An exhaustive toEqual is the right assertion here -- it is what makes a new member of the vocabulary record a decision somebody has to make rather than one that arrives unnoticed. Adding the field to the expectation, not loosening the assertion. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/store-scheme-sites.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts index c347b68b5..1f04aacb0 100644 --- a/server/tests/store-scheme-sites.test.ts +++ b/server/tests/store-scheme-sites.test.ts @@ -138,11 +138,13 @@ test("BROKERED_PROFILE lives in access.ts and answers for both brokers", () => { scheme: "composio://", idPrefix: "composio-", vendor: "Composio", + envVar: "COMPOSIO_API_KEY", }); expect(BROKERED_PROFILE.arcade).toEqual({ scheme: "arcade://", idPrefix: "arcade-", vendor: "Arcade", + envVar: "ARCADE_API_KEY", }); }); From 420fb315851e67a2f2eac12f6b8b47d42d90114b Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 12:41:18 +0200 Subject: [PATCH 096/232] test(arcade): the whole deployment, from the picker to the disconnect, through createApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Micro-task 13.1. One integration suite drives the Arcade journey through `createApp` itself — select Arcade, browse the catalogue, enable two apps, discover their actions, load the connected-accounts index, read what the account holds, authorize an action, come back through the verifier, execute a call, disconnect — and asserts the PERSISTED fact at each stage rather than a status code: `provenance`/`vendor`/`url` with the toolkit's capitals intact, `mcp_tools` rows carrying `effect`/`destructive` off the recorded `metadata.behavior`, a `pending_authorizations` row holding the resolved `AccountRef`, `brokered_connections.provider = 'arcade'`, exactly one `mcp.account_connected` naming the action and the scopes, the identity the transport sent upstream, and — on disconnect — the absence of every local row plus one audit row per app the revocation ended. The second half is the merge gate: two apps are switched to Shared under ONE auth provider at the vendor, and that they keep SEPARATE identities and SEPARATE connections is exercised through the product — the `user_id` each mint was sent, the two rows, each app's own account name on the index, the sibling preview answering empty, and one disconnect leaving the other standing. Nothing is dialled. The vendor is stubbed at the SDK prototype, the seam the other Arcade adapter suites use, so the real adapter and the real transport run and only the wire is fake. The recorded fixture is read, never appended to: its `read`, `write`, `destructive` and `unclassified` metadata objects are what the constructed toolkits carry. NO SOURCE DEFECT WAS CAUGHT. The suite went green against the branch as it stands, including on the four assertions written specifically to break a plausible one: the four enablement counts held apart by an unannotated, unscoped action; `GET /servers/:id/grants` answering `needs-consent` for every action while the listing's `requirements.met` is true for all of them; the lowercased toolkit refused at enablement and the lowercased action refused at the mint; and a second Refresh replacing the recorded actions rather than adding to them. Reported as a finding rather than written up as a fix. Fixes to the predecessor's uncommitted draft, which had never been run: - the verifier test drove a SECOND mint, which `claimAuthorizationAttempt` answers 409 while a live attempt holds the actor key, so nothing went out and the return leg was silently verifying the earlier mint. The mint is now handed between the two tests and the mint count is asserted. - `executeCalls` and `deleted` were `let` where nothing reassigns them. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/arcade-journey.integration.test.ts | 1035 +++++++++++++++++ 1 file changed, 1035 insertions(+) create mode 100644 server/tests/arcade-journey.integration.test.ts diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts new file mode 100644 index 000000000..b2fd30d54 --- /dev/null +++ b/server/tests/arcade-journey.integration.test.ts @@ -0,0 +1,1035 @@ +// server/tests/arcade-journey.integration.test.ts +/** + * THE WHOLE ARCADE DEPLOYMENT, END TO END, THROUGH `createApp`. + * + * Select Arcade → browse the catalogue → enable an app → discover its actions → load the + * connected-accounts index → authorize one action → come back through the verifier → execute a + * call → disconnect. Every other Arcade suite checks one of those against its own factory; this one + * is the only place they are checked against EACH OTHER, which is where a vendor's name written by + * hand in one step and read by a parser in the next shows up. + * + * SO EVERY ASSERTION IS A PERSISTED FACT AND NEVER A STATUS CODE. A 201 says a route answered; what + * the journey turns on is the ROW it left — `arcade://GoogleCalendar-…` with its capitals intact, + * an `mcp_tools` row carrying the vendor's own `behavior`, a `pending_authorizations` row holding + * the resolved identity, a `brokered_connections` row stamped `arcade`, an audit entry naming the + * action and the scopes, and — on disconnect — the absence of all of them. + * + * AND THE SECOND HALF IS THE MERGE GATE. Two apps are switched to Shared, and the whole of what + * keeps them apart is that each holds its own minted identity: the two sit under ONE auth provider + * at the vendor, which is the configuration where a shared identity would be invisible until + * somebody pressed Disconnect on one app and lost the other. That is exercised THROUGH THE PRODUCT + * here — the mint the vendor was sent, the rows, the sibling preview and the disconnect — rather + * than inferred from the unique indexes. + * + * NOTHING IS DIALLED. The vendor is stubbed at the SDK PROTOTYPE, the seam every other Arcade + * adapter suite uses (`new Arcade({ apiKey })` opens no socket), so the REAL adapter and the REAL + * transport run: `listApps`, `authProviderOf`, `authorize`, `auth.status`, `auth.confirmUser`, + * `admin.userConnections` and `tools.execute` are this deployment's own code throughout, and only + * the wire is fake. + * + * THE FIXTURE IS EVIDENCE AND THE TOOLKITS ARE A CASE. `arcade-catalogue.json` is read for the one + * thing only a recording can be trusted about — the exact `metadata` a read, a write, a destructive + * and an UNANNOTATED action are published with — and nothing is appended to it. The toolkits themselves are + * constructed here, because the journey needs two apps sharing ONE auth provider, which is the + * live `Gmail`/`GoogleCalendar`-under-`google` shape the adapter's own docblock describes and which + * no single recorded page carries. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import Arcade from "@arcadeai/arcadejs"; +import { and, eq, inArray } from "drizzle-orm"; +import { createApp } from "../src/app"; +import { createAuditStore } from "../src/audit"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { + agents, + auditEvents, + brokeredConnections, + mcpServers, + mcpTools, + pendingAuthorizations, + pluginGrants, +} from "../src/db/schema"; +import { createAccountModeSwitch } from "../src/plugins/account-mode"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createSharedUseStore } from "../src/plugins/shared-use-store"; +import { createPluginStore } from "../src/plugins/store"; +import fixture from "./fixtures/arcade-catalogue.json"; +import { testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +/* + * FOUR CONNECTIONS rather than the shared `TEST_POOL` of two: the completion transaction holds one + * while the reads that check what it wrote take another, and `support/database.ts` says a file + * wanting a different pool asks for one itself. + */ +const database = createDatabase(testDatabaseUrl(), { max: 4 }); + +const suite = randomUUID().slice(0, 8); +/** + * Two toolkits whose CAPITALS CARRY MEANING. `GoogleCalendar-…` is the name a lowercasing step + * anywhere between the picker and the transport would destroy, and the suite suffix is what makes + * `audit_events` — which is append-only and cannot be cleaned between tests — answer about this run + * and no other. + */ +const MAIL = `Gmail-${suite}`; +const DIARY = `GoogleCalendar-${suite}`; +const MAIL_ROW = `arcade-${MAIL}`; +const DIARY_ROW = `arcade-${DIARY}`; +const SEND = `${MAIL}.SendEmail`; +const DELETE_EVENT = `${DIARY}.DeleteEvent`; + +/** + * ONE AUTH PROVIDER FOR BOTH TOOLKITS, which is the whole point of the pair. + * + * `provider` is the BROKER (`arcade`); this is the VENDOR'S OAuth provider, and one Google + * configuration serves Gmail and GoogleCalendar both. Deliberately nothing like either toolkit + * name, so anything deriving one from the other fails here. + */ +const AUTH_PROVIDER = "google"; + +const ADMIN = `arcade-journey-${suite}`; +const BOT = `bot-${suite}`; +const APP_URL = "https://app.example"; +/** A stand-in for the live `gho_…` the vendor's status reply carries beside the three fields. */ +const LIVE_TOKEN = `gho_live_access_token_${suite}`; + +const SCOPES: Record = { + [`${MAIL}.ListEmails`]: ["https://www.googleapis.com/auth/gmail.readonly"], + [SEND]: ["https://www.googleapis.com/auth/gmail.send"], + [`${MAIL}.TrashEmail`]: ["https://www.googleapis.com/auth/gmail.modify"], + [`${DIARY}.ListEvents`]: [ + "https://www.googleapis.com/auth/calendar.readonly", + ], + [DELETE_EVENT]: ["https://www.googleapis.com/auth/calendar.events"], +}; + +/** + * The `metadata` shapes, READ OUT OF THE RECORDING rather than written here — the whole object and + * not just its `behavior`, so what the fold reads is the shape the vendor publishes. What a vendor + * actually publishes is the one thing a hand-written literal cannot be trusted about, and it is + * exactly what `classify` reads. + * + * `unclassified` IS A RECORDED `metadata: null`, AND IT IS WHAT MAKES THE COUNTS DISCRIMINATING. It + * classifies to the SAME closed `{ write, destructive }` verdict a genuinely destructive action + * does, so a toolkit whose every action is annotated cannot tell `classifiedCount` apart from + * `actionCount` — the coverage warning that exists to name unannotated actions would be green + * against a count that simply echoed the other. + */ +const recorded = fixture.tools as unknown as Record< + string, + { metadata: unknown } +>; +const METADATA = { + read: recorded.read.metadata, + write: recorded.write.metadata, + destructive: recorded.destructive.metadata, + unclassified: recorded.unclassified.metadata, +}; + +function action( + toolkit: string, + name: string, + metadata: unknown, +): Record { + const qualified = `${toolkit}.${name}`; + return { + fully_qualified_name: `${qualified}@1.0.0`, + qualified_name: qualified, + name, + description: `${name} at ${toolkit}`, + toolkit: { name: toolkit, description: `Arcade tools for ${toolkit}` }, + input: { + parameters: [ + { + name: "q", + required: true, + description: "What to act on.", + value_schema: { val_type: "string" }, + }, + ], + }, + requirements: { + /* + * TRUE FOR EVERYBODY, AND NOTHING MAY READ IT. `requirements.met` answers true on a listing + * asked without a `user_id`, which is every listing on this journey bar `grantsFor`'s. A step + * that read it would record this run as connected to everything before a single consent, and + * the journey below would still be green — so it is set true here on purpose. + */ + met: true, + authorization: { + id: `arcade-${AUTH_PROVIDER}`, + provider_id: AUTH_PROVIDER, + provider_type: "oauth2", + oauth2: { scopes: SCOPES[qualified] ?? [] }, + status: "inactive", + }, + }, + metadata, + }; +} + +/** + * `DraftEmail` carries the recorded `metadata: null` AND NO SCOPES, which is the live + * `Github`-shaped action: an `oauth2` block with an empty scope list, where the connection itself + * is the grant. It is what separates all four of the picker's counts from one another. + */ +const CATALOGUE: Record[] = [ + action(MAIL, "ListEmails", METADATA.read), + action(MAIL, "SendEmail", METADATA.write), + action(MAIL, "TrashEmail", METADATA.destructive), + action(MAIL, "DraftEmail", METADATA.unclassified), + action(DIARY, "ListEvents", METADATA.read), + action(DIARY, "DeleteEvent", METADATA.destructive), +]; + +/** The same listed action as the vendor publishes it to an identity that has consented. */ +function granted(item: Record): Record { + const requirements = item.requirements as Record; + const authorization = requirements.authorization as Record; + return { + ...item, + requirements: { + ...requirements, + authorization: { ...authorization, status: "active" }, + }, + }; +} + +/* ------------------------------------------------------------------ the vendor, at its own wire */ + +type VendorConnection = { + id: string; + connection_id: string; + provider_id: string; + user_id: string; + provider_user_info: { name: string }; +}; + +/** What the vendor holds. One row per (identity, auth provider), which is how Arcade keys them. */ +let vendorConnections: VendorConnection[] = []; +let authorizeCalls: Record[] = []; +const executeCalls: Record[] = []; +const deleted: string[] = []; +let mints = 0; +/** Every mint, by its `ar_…` id, and the newest one per identity for the return leg to name. */ +const minted = new Map(); +const newestMint = new Map(); + +type Methods = Record unknown>; +const protoOf = (pick: (client: Arcade) => unknown): Methods => + Object.getPrototypeOf(pick(new Arcade({ apiKey: "never-dialled" }))); + +const restores: (() => void)[] = []; +function stub( + pick: (client: Arcade) => unknown, + name: string, + impl: (...args: unknown[]) => unknown, +) { + const on = protoOf(pick); + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} + +/** The vendor's connection for one identity under this journey's single auth provider. */ +function holdConnection(userId: string) { + if (vendorConnections.some((row) => row.user_id === userId)) return; + const n = vendorConnections.length + 1; + vendorConnections.push({ + id: `at_${n}`, + connection_id: `ac_${n}`, + provider_id: AUTH_PROVIDER, + user_id: userId, + provider_user_info: { name: `Account ${n}` }, + }); +} + +function installVendor() { + stub( + (client) => client.tools, + "list", + (...args: unknown[]) => { + const query = (args[0] ?? {}) as { + toolkit?: string; + limit?: number; + offset?: number; + user_id?: string; + }; + const items = CATALOGUE.filter( + (item) => + !query.toolkit || + (item.toolkit as { name: string }).name === query.toolkit, + ).map((item) => + /* + * A LISTING ASKED ABOUT SOMEBODY ANSWERS ABOUT THEM, which is the only difference the + * `user_id` makes and the whole of what `grantsFor` is reading. `met` stays true on every + * item either way (see `action`), so a step reading it instead of the authorization's own + * status would report this run as holding every grant before a single consent. + */ + query.user_id && + vendorConnections.some((r) => r.user_id === query.user_id) + ? granted(item) + : item, + ); + const offset = query.offset ?? 0; + const limit = query.limit ?? items.length; + return Promise.resolve({ + items: items.slice(offset, offset + limit), + total_count: items.length, + }); + }, + ); + + stub( + (client) => client.tools, + "authorize", + (...args: unknown[]) => { + const request = args[0] as { tool_name: string; user_id: string }; + authorizeCalls.push({ ...request }); + mints += 1; + const id = `ar_${mints}`; + minted.set(id, { + userId: request.user_id, + scopes: SCOPES[request.tool_name] ?? [], + }); + newestMint.set(request.user_id, id); + return Promise.resolve({ + id, + // A BEARER CAPABILITY. It goes to the caller and nowhere else; the suite checks that + // nothing else it touches ever carries it. + url: `https://vendor.example/consent/${id}?secret=bearer-capability`, + status: "pending", + }); + }, + ); + + stub( + (client) => client.tools, + "execute", + (...args: unknown[]) => { + const request = args[0] as { + tool_name: string; + input: Record; + user_id: string; + }; + executeCalls.push({ ...request }); + return Promise.resolve({ + success: true, + output: { value: { ran: request.tool_name } }, + }); + }, + ); + + stub( + (client) => client.auth, + "confirmUser", + (...args: unknown[]) => { + const request = args[0] as { flow_id: string; user_id: string }; + // The consent finished at the vendor BEFORE this deployment looked, which is the true order. + holdConnection(request.user_id); + return Promise.resolve({ + auth_id: newestMint.get(request.user_id) ?? "ar_unknown", + }); + }, + ); + + stub( + (client) => client.auth, + "status", + (...args: unknown[]) => { + const { id } = args[0] as { id: string }; + const mint = minted.get(id); + /* + * DELIBERATELY WIDER THAN THE SEAM. The live reply carries a working provider access token + * and the person's whole profile beside the three fields the adapter narrows to, and the + * point of sending them here is that nothing downstream can be carrying either. + */ + return Promise.resolve({ + id, + status: "completed", + user_id: mint?.userId ?? "", + scopes: mint?.scopes ?? [], + context: { token: LIVE_TOKEN, user_info: { email: "person@vendor" } }, + url: `https://vendor.example/consent/${id}?secret=bearer-capability`, + }); + }, + ); + + stub( + (client) => client.admin.userConnections, + "list", + (...args: unknown[]) => { + const { user_id: userId } = args[0] as { user_id: string }; + const rows = vendorConnections.filter((row) => row.user_id === userId); + return (async function* () { + for (const row of rows) yield row; + })(); + }, + ); + + stub( + (client) => client.admin.userConnections, + "delete", + (...args: unknown[]) => { + const id = args[0] as string; + deleted.push(id); + vendorConnections = vendorConnections.filter( + (row) => row.connection_id !== id, + ); + return Promise.resolve(); + }, + ); +} + +/* --------------------------------------------------------------------- the deployment, as booted */ + +let broker: ConnectedAppBroker; + +/** + * The deployment as `createApp` builds it. + * + * Positional, by index rather than by a run of `undefined`s, for the reason + * `shared-wiring.integration.test.ts` gives: a parameter added in the middle then moves a type + * error onto the wrong index here rather than shifting an argument silently. + */ +function build() { + const auditStore = createAuditStore(database); + const pluginStore = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore, + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); + const use = createSharedUseStore(database, "arcade"); + const modes = createAccountModeSwitch({ + store: pluginStore, + sharedUse: use, + teamRules: undefined, + deploymentId: `acme-${suite}`, + audit: auditStore, + }); + const args = new Array(createApp.length) as unknown as Parameters< + typeof createApp + >; + args[0] = loadConfig(testEnvironment({ OPENBOT_APP_URL: APP_URL })); + args[1] = { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => ({ + user: { id: ADMIN, email: `${ADMIN}@example.test`, name: "Admin" }, + }), + }, + } as never; + args[2] = { rolesForUser: async () => ["admin"] } as never; + args[12] = auditStore; + args[14] = pluginStore; + args[29] = { broker, provider: "arcade" } as never; + args[34] = { shared: { modes, use } } as never; + return createApp(...args); +} + +const api = (path: string, init?: RequestInit) => + build().request(`http://openbot.test/api/plugins${path}`, init); + +const send = (method: string, path: string, body: unknown) => + api(path, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +/* -------------------------------------------------------------------------------- what persisted */ + +async function serverRow(id: string) { + const [row] = await database + .select() + .from(mcpServers) + .where(eq(mcpServers.id, id)); + return row ?? null; +} + +async function toolRows(serverId: string) { + return ( + await database + .select({ + name: mcpTools.name, + effect: mcpTools.effect, + destructive: mcpTools.destructive, + }) + .from(mcpTools) + .where(eq(mcpTools.serverId, serverId)) + ).sort((left, right) => (left.name < right.name ? -1 : 1)); +} + +async function connectionsOf(app: string) { + return await database + .select() + .from(brokeredConnections) + .where(eq(brokeredConnections.app, app)); +} + +async function eventsOn(app: string, eventType: string) { + return await database + .select() + .from(auditEvents) + .where( + and(eq(auditEvents.targetId, app), eq(auditEvents.eventType, eventType)), + ); +} + +async function attemptRow() { + const [row] = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, ADMIN)); + return row ?? null; +} + +/** + * One whole consent: mint through the product, come back through the verifier. + * + * THE TWO HALVES ARE ONE OBJECT BECAUSE THE ATTEMPT KEY IS ONE ROW. `pending_authorizations` is + * keyed on the actor, so a second mint by the same person while the first is live is answered 409 + * and NOTHING GOES OUT — a return leg driven from a fresh `consent()` call would be verifying the + * EARLIER mint while reading as though it had just made one. So a mint is handed back with its own + * return leg attached, and the suite completes the mint it actually made. + */ +async function consent(serverRowId: string, actionName: string) { + const minting = await send("POST", `/servers/${serverRowId}/authorize`, { + action: actionName, + }); + const body = (await minting.json()) as { authorizationUrl?: string }; + return { + minting, + authorizationUrl: body.authorizationUrl, + returning: () => + api(`/arcade/verify?flow_id=flow-${encodeURIComponent(actionName)}`), + }; +} + +beforeAll(async () => { + installVendor(); + const client = createArcadeClient("never-dialled"); + broker = client.broker; + useArcadeClient(client.actions); + await database.insert(agents).values({ + id: BOT, + name: "Helper", + type: "built_in", + configuration: {}, + }); +}); + +afterAll(async () => { + useArcadeClient(null); + while (restores.length) restores.pop()?.(); + await database + .delete(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, ADMIN)); + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, [MAIL, DIARY])); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, [MAIL_ROW, DIARY_ROW])); + await database.delete(agents).where(eq(agents.id, BOT)); +}); + +describe("an Arcade deployment, from the picker to the disconnect", () => { + /** The personal leg's one mint, completed by the verifier test below. See {@link consent}. */ + let personal: Awaited> | null = null; + + test("the catalogue an administrator chooses from is Arcade's own", async () => { + const response = await api("/composio/apps"); + expect(response.status).toBe(200); + const { apps } = (await response.json()) as { + apps: Record[]; + }; + const mail = apps.find((app) => app.slug === MAIL); + const diary = apps.find((app) => app.slug === DIARY); + + /* + * COUNTED ACROSS THE FULL LISTING, and the toolkit names come back exactly as published. The + * four counts are four different numbers here on purpose: `DraftEmail` is annotated by nobody + * and asks for no scopes, so a count derived from any of the others is wrong about one of them. + */ + expect(mail).toMatchObject({ + slug: MAIL, + name: MAIL, + actionCount: 4, + classifiedCount: 3, + scopedActionCount: 3, + permissionCount: 3, + enabled: false, + }); + expect(diary).toMatchObject({ + slug: DIARY, + actionCount: 2, + classifiedCount: 2, + enabled: false, + }); + }); + + test("enabling an app writes an arcade row whose url keeps the vendor's case", async () => { + /* + * THE LOWERCASED TOOLKIT IS NOT AN APP ARCADE LISTS, and enabling it would write a second row + * at `arcade://gmail-…` that every later call resolves against and that reaches nothing. The + * directory is what the slug is checked against rather than a pattern, so this is that check + * read through the one listing both halves of the picker share. + */ + const folded = await send("POST", "/composio/apps", { + slug: MAIL.toLowerCase(), + }); + expect(folded.status).toBe(400); + expect(await serverRow(`arcade-${MAIL.toLowerCase()}`)).toBeNull(); + + for (const toolkit of [MAIL, DIARY]) { + const response = await send("POST", "/composio/apps", { slug: toolkit }); + expect([response.status, toolkit]).toEqual([201, toolkit]); + } + + for (const [toolkit, rowId] of [ + [MAIL, MAIL_ROW], + [DIARY, DIARY_ROW], + ]) { + const row = await serverRow(rowId); + expect(row).toMatchObject({ + id: rowId, + // THE BROKER, spelled as the column spells it — never derived at the call site. + provenance: "arcade", + vendor: "Arcade", + url: `arcade://${toolkit}`, + accountMode: "personal", + }); + // CASE INTACT. `GoogleCalendar-…` lowercased reaches nothing at Arcade, and this is the one + // place the name crosses from the vendor's catalogue into a column. + expect(row?.url).not.toBe(row?.url.toLowerCase()); + expect(row?.authScheme).not.toBeNull(); + } + + // And the picker now says so, through the same parser the transport dials with. + const { apps } = (await (await api("/composio/apps")).json()) as { + apps: { slug: string; enabled: boolean }[]; + }; + expect( + apps + .filter((app) => [MAIL, DIARY].includes(app.slug)) + .map((app) => app.enabled), + ).toEqual([true, true]); + }); + + test("discovery records the effect and the destructive flag the vendor published", async () => { + for (const rowId of [MAIL_ROW, DIARY_ROW]) { + const response = await send("POST", `/servers/${rowId}/refresh`, {}); + expect([response.status, rowId]).toEqual([200, rowId]); + } + + /* + * TWICE, because an administrator presses Refresh twice and the second press must replace what + * the first recorded rather than add to it: a duplicated `mcp_tools` row is an action a model is + * offered twice and a grant that names two rows. + */ + const again = await send("POST", `/servers/${MAIL_ROW}/refresh`, {}); + expect(again.status).toBe(200); + + expect(await toolRows(MAIL_ROW)).toEqual([ + // NOTHING SAID, SO IT FAILS CLOSED: an unannotated action is approved like a deletion. + { name: `${MAIL}.DraftEmail`, effect: "write", destructive: true }, + { name: `${MAIL}.ListEmails`, effect: "read", destructive: false }, + { name: `${MAIL}.SendEmail`, effect: "write", destructive: false }, + { name: `${MAIL}.TrashEmail`, effect: "write", destructive: true }, + ]); + expect(await toolRows(DIARY_ROW)).toEqual([ + { name: `${DIARY}.DeleteEvent`, effect: "write", destructive: true }, + { name: `${DIARY}.ListEvents`, effect: "read", destructive: false }, + ]); + }); + + test("the connected-accounts index answers, and claims no account nobody made", async () => { + const response = await api("/connections"); + expect(response.status).toBe(200); + const { connections } = (await response.json()) as { + connections: { serverId: string }[]; + }; + expect( + connections.filter((row) => [MAIL_ROW, DIARY_ROW].includes(row.serverId)), + ).toEqual([]); + }); + + test("the account page says what is held before a consent, and reads no met flag to say it", async () => { + const { actions } = (await ( + await api(`/servers/${MAIL_ROW}/grants`) + ).json()) as { actions: { action: string; state: string }[] }; + + /* + * NOTHING IS HELD, AND `requirements.met` IS TRUE FOR EVERY ONE OF THESE ACTIONS. The two sit + * in the same object and behave oppositely, and this is the assertion that tells them apart: a + * read of `met` answers `authorized` for all four before anybody has consented to anything, + * and the only visible consequence would be a page offering to call somebody else's mailbox. + */ + expect(actions.map((entry) => [entry.action, entry.state]).sort()).toEqual([ + [`${MAIL}.DraftEmail`, "needs-consent"], + [`${MAIL}.ListEmails`, "needs-consent"], + [`${MAIL}.SendEmail`, "needs-consent"], + [`${MAIL}.TrashEmail`, "needs-consent"], + ]); + }); + + test("authorizing one action claims an attempt holding the RESOLVED identity", async () => { + /* + * THE SAME ACTION IN LOWER CASE IS NOT AN ACTION THIS APP PUBLISHES, and that is the discovery + * above read back through the authorize route's own `===`. It is checked BEFORE the real mint + * so that nothing has been claimed: a route that folded case here would mint a consent for an + * action name that reaches nothing at Arcade, and the 400 is what says the stored names kept + * their capitals all the way from the catalogue. + */ + const folded = await send("POST", `/servers/${MAIL_ROW}/authorize`, { + action: SEND.toLowerCase(), + }); + expect(folded.status).toBe(400); + // NOTHING WENT OUT AND NOTHING WAS CLAIMED, which is what makes the mint below the first one. + expect(authorizeCalls).toEqual([]); + expect(await attemptRow()).toBeNull(); + + personal = await consent(MAIL_ROW, SEND); + const { minting, authorizationUrl } = personal; + expect(minting.status).toBe(200); + expect(authorizationUrl).toBe( + "https://vendor.example/consent/ar_1?secret=bearer-capability", + ); + + /* + * WHAT WENT OUT, AND NOTHING ELSE WENT WITH IT. `next_uri` is declared by the SDK and refused by + * the API for every value, so the mint carries exactly two fields. + */ + expect(authorizeCalls).toEqual([{ tool_name: SEND, user_id: ADMIN }]); + + const attempt = await attemptRow(); + expect(attempt).toMatchObject({ + serverId: MAIL_ROW, + action: SEND, + holder: "person", + userId: ADMIN, + vendorUserId: ADMIN, + authId: "ar_1", + completedAt: null, + outcome: null, + }); + }); + + test("the verifier writes one arcade connection and one audit entry naming the action and the scopes", async () => { + // THE RETURN LEG OF THE MINT THE TEST ABOVE MADE, and of no other: see {@link consent}. + const response = await personal?.returning(); + expect(response?.status).toBe(303); + // STILL ONE MINT. A second one here would mean the return leg was verifying somebody else's. + expect(authorizeCalls).toEqual([{ tool_name: SEND, user_id: ADMIN }]); + expect(response?.headers.get("location")).toBe( + `${APP_URL}/settings/connected-accounts/${MAIL_ROW}?action=${encodeURIComponent(SEND)}`, + ); + + const connections = await connectionsOf(MAIL); + expect(connections.length).toBe(1); + expect(connections[0]).toMatchObject({ + provider: "arcade", + app: MAIL, + holder: "person", + userId: ADMIN, + vendorUserId: ADMIN, + verified: true, + }); + + const events = await eventsOn(MAIL, "mcp.account_connected"); + expect(events.length).toBe(1); + expect(events[0]?.payload).toMatchObject({ + action: SEND, + scope: "https://www.googleapis.com/auth/gmail.send", + // THE BROKER'S ANSWER, read off the listing and never derived from `Gmail-…`. + authProvider: AUTH_PROVIDER, + }); + // NOTHING FROM THE STATUS REPLY TRAVELS. It carries a live provider access token. + expect(JSON.stringify(events[0]?.payload)).not.toContain(LIVE_TOKEN); + expect(JSON.stringify(events[0]?.payload)).not.toContain("vendor.example"); + + expect(await attemptRow()).toMatchObject({ outcome: "completed" }); + + /* + * AND THE INDEX NOW DRAWS IT, as the row the completion wrote rather than merely as an id: + * `verified` is what the consent earned and `probe` is what nothing was spent on, and a page + * reading one as the other is the pair `brokeredConnectionsFor` exists to keep apart. + */ + const { connections: listed } = (await ( + await api("/connections") + ).json()) as { + connections: { serverId: string; holder: string; verified: boolean }[]; + }; + expect( + listed.filter((row) => [MAIL_ROW, DIARY_ROW].includes(row.serverId)), + ).toMatchObject([{ serverId: MAIL_ROW, holder: "person", verified: true }]); + + // AND THE ACCOUNT PAGE NOW READS THE CONSENT BACK OFF THE VENDOR, under this identity. + const { actions } = (await ( + await api(`/servers/${MAIL_ROW}/grants`) + ).json()) as { actions: { action: string; state: string }[] }; + expect(new Set(actions.map((entry) => entry.state))).toEqual( + new Set(["authorized"]), + ); + }); + + test("a call goes out as the identity this deployment resolved", async () => { + await database.insert(pluginGrants).values({ + kind: "mcp", + ref: `${MAIL_ROW}/${SEND}`, + agentId: BOT, + grantedBy: ADMIN, + }); + + const response = await send("POST", "/call", { + ref: `${MAIL_ROW}/${SEND}`, + agentId: BOT, + args: { q: "hello" }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ isError: false }); + + expect(executeCalls).toEqual([ + { tool_name: SEND, input: { q: "hello" }, user_id: ADMIN }, + ]); + }); + + test("a second app on the same identity is its own connection", async () => { + const response = await (await consent(DIARY_ROW, DELETE_EVENT)).returning(); + expect(response.status).toBe(303); + + const diary = await connectionsOf(DIARY); + expect(diary.length).toBe(1); + expect(diary[0]).toMatchObject({ + provider: "arcade", + holder: "person", + vendorUserId: ADMIN, + }); + // One identity, one auth provider: the vendor holds ONE connection serving both apps. + expect(vendorConnections.map((row) => row.user_id)).toEqual([ADMIN]); + }); + + test("disconnecting ends every app that revocation ends, and says so on the trail", async () => { + // The warning an administrator is shown before the press, from the store and never derived here. + const preview = await api(`/servers/${MAIL_ROW}/connection/siblings`); + expect(await preview.json()).toEqual({ siblings: [DIARY], unanswered: 0 }); + + const response = await api(`/servers/${MAIL_ROW}/connection`, { + method: "DELETE", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ vendorRevocationRequested: true }); + + // EVERY LOCAL ROW ENDED. A row left for the diary would claim an account the vendor no longer + // holds, which is the other way to lie. + expect(await connectionsOf(MAIL)).toEqual([]); + expect(await connectionsOf(DIARY)).toEqual([]); + expect(vendorConnections).toEqual([]); + // THE `ac_…` connection_id, never the `at_…` id the listing prints first. + expect(deleted).toEqual(["ac_1"]); + + const mailEvents = await eventsOn(MAIL, "mcp.account_disconnected"); + const diaryEvents = await eventsOn(DIARY, "mcp.account_disconnected"); + expect(mailEvents.length).toBe(1); + expect(diaryEvents.length).toBe(1); + expect(mailEvents[0]?.payload).toMatchObject({ + server: MAIL, + owner: ADMIN, + reason: "self", + vendorRevocationRequested: true, + unansweredSiblings: 0, + }); + // The sibling's row names the app whose disconnect ended it. + expect(diaryEvents[0]?.payload).toMatchObject({ + server: DIARY, + endedWith: MAIL, + }); + + // AND THE INDEX CLAIMS NEITHER OF THEM ANY MORE, which is where somebody would otherwise be + // shown a Connected row over an account Arcade no longer holds. + const { connections } = (await (await api("/connections")).json()) as { + connections: { serverId: string }[]; + }; + expect( + connections.filter((row) => [MAIL_ROW, DIARY_ROW].includes(row.serverId)), + ).toEqual([]); + }); +}); + +/** + * THE MERGE GATE. + * + * Two apps, both Shared, both under ONE auth provider at the vendor. The only thing keeping their + * accounts apart is that each app holds its OWN minted identity, and the test of that is the + * product: what the mint was sent, what the rows say, what the sibling preview answers, and what + * survives a disconnect. + */ +describe("two separately-shared apps", () => { + let mailIdentity = ""; + let diaryIdentity = ""; + + test("each gets its own deployment identity when it is switched to Shared", async () => { + for (const rowId of [MAIL_ROW, DIARY_ROW]) { + const response = await send("PUT", `/servers/${rowId}/account-mode`, { + mode: "shared", + confirm: true, + }); + expect([response.status, rowId]).toEqual([200, rowId]); + expect(await response.json()).toMatchObject({ changed: true }); + } + + mailIdentity = (await serverRow(MAIL_ROW))?.sharedVendorUserId ?? ""; + diaryIdentity = (await serverRow(DIARY_ROW))?.sharedVendorUserId ?? ""; + + expect(mailIdentity).toStartWith(`openbot-deployment:acme-${suite}:`); + expect(diaryIdentity).toStartWith(`openbot-deployment:acme-${suite}:`); + // SEPARATE. A shared identity here is the defect this whole describe exists to catch. + expect(mailIdentity).not.toBe(diaryIdentity); + + // And the index draws a Shared app whether or not anything is connected to it yet. + const { connections } = (await (await api("/connections")).json()) as { + connections: { serverId: string; holder: string; connected: boolean }[]; + }; + expect( + connections + .filter((row) => [MAIL_ROW, DIARY_ROW].includes(row.serverId)) + .map((row) => ({ id: row.serverId, ...row, holder: row.holder })), + ).toMatchObject([ + { serverId: MAIL_ROW, holder: "deployment", connected: false }, + { serverId: DIARY_ROW, holder: "deployment", connected: false }, + ]); + }); + + test("each app's consent is minted and recorded under its own identity", async () => { + authorizeCalls = []; + + for (const [rowId, actionName, identity, app] of [ + [MAIL_ROW, SEND, mailIdentity, MAIL], + [DIARY_ROW, DELETE_EVENT, diaryIdentity, DIARY], + ] as const) { + const minting = await consent(rowId, actionName); + expect(minting.minting.status).toBe(200); + + const attempt = await attemptRow(); + expect(attempt).toMatchObject({ + serverId: rowId, + action: actionName, + holder: "deployment", + userId: null, + vendorUserId: identity, + }); + + const response = await minting.returning(); + expect(response.status).toBe(303); + // A deployment's account goes back to the admin screen it was started from. + expect(response.headers.get("location")).toBe( + `${APP_URL}/admin/plugins/${rowId}?action=${encodeURIComponent(actionName)}`, + ); + + const connections = await connectionsOf(app); + expect(connections.length).toBe(1); + expect(connections[0]).toMatchObject({ + provider: "arcade", + holder: "deployment", + userId: null, + vendorUserId: identity, + verified: true, + }); + } + + // WHAT THE VENDOR WAS SENT, per app, which is where a shared identity would show first. + expect(authorizeCalls).toEqual([ + { tool_name: SEND, user_id: mailIdentity }, + { tool_name: DELETE_EVENT, user_id: diaryIdentity }, + ]); + // TWO CONNECTIONS AT THE VENDOR under ONE auth provider, kept apart by identity alone. + expect(vendorConnections.map((row) => row.user_id)).toEqual([ + mailIdentity, + diaryIdentity, + ]); + expect(new Set(vendorConnections.map((row) => row.provider_id)).size).toBe( + 1, + ); + + /* + * AND THE INDEX DRAWS EACH APP'S OWN ACCOUNT, which is the merge gate at the one place it is + * VISIBLE. Both rows stand at once, under one auth provider at the vendor, so the name beside + * each is read through `accountName` for THAT app's identity — an account-name lookup that + * asked by provider alone would answer both rows with the first identity's account, and + * somebody would be shown the wrong team mailbox on a page offering to disconnect it. + */ + const { connections } = (await (await api("/connections")).json()) as { + connections: { + serverId: string; + holder: string; + connected: boolean; + verified: boolean; + displayName: string | null; + }[]; + }; + expect( + connections.filter((row) => [MAIL_ROW, DIARY_ROW].includes(row.serverId)), + ).toMatchObject([ + { + serverId: MAIL_ROW, + holder: "deployment", + connected: true, + verified: true, + displayName: "Account 1", + }, + { + serverId: DIARY_ROW, + holder: "deployment", + connected: true, + verified: true, + displayName: "Account 2", + }, + ]); + }); + + test("ending one leaves the other standing, and nothing says otherwise", async () => { + // NOT SIBLINGS, though they share an auth provider: the identities differ. + const preview = await api(`/servers/${MAIL_ROW}/connection/siblings`); + expect(await preview.json()).toEqual({ siblings: [], unanswered: 0 }); + + const before = (await eventsOn(DIARY, "mcp.account_disconnected")).length; + + const response = await api(`/servers/${MAIL_ROW}/connection`, { + method: "DELETE", + }); + expect(response.status).toBe(200); + + expect(await connectionsOf(MAIL)).toEqual([]); + const diary = await connectionsOf(DIARY); + expect(diary.length).toBe(1); + expect(diary[0]).toMatchObject({ vendorUserId: diaryIdentity }); + expect(vendorConnections.map((row) => row.user_id)).toEqual([ + diaryIdentity, + ]); + + // The diary was not disconnected, so nothing on the trail says it was. + expect((await eventsOn(DIARY, "mcp.account_disconnected")).length).toBe( + before, + ); + const mailEvents = await eventsOn(MAIL, "mcp.account_disconnected"); + expect(mailEvents.length).toBe(2); + expect(mailEvents.at(-1)?.payload).toMatchObject({ + server: MAIL, + owner: "deployment", + reason: "admin", + unansweredSiblings: 0, + }); + }); +}); From 36b428363f35b19f5b8dd7c7b3f9ff5dcb104180 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 12:44:25 +0200 Subject: [PATCH 097/232] chore(test): commit the sharded server-suite runner the gates depend on `bun test server/tests` as a whole no longer runs. Around a hundred of those files open a module-scope connection pool, PostgreSQL's default `max_connections` is 100, and the run dies with `sorry, too many clients already` during the import phase, before it reports anything. The sweep that works was a scratch script passed between sessions, which is how a branch stayed red for several waves: `store-scheme-sites.test.ts` was broken by a commit whose targeted run did not include it, `typecheck` stayed green because `server/tests` is outside `tsc`, a reviewer read the diff, and three micro-tasks landed on top. A sharded sweep is the only thing that has ever caught that class, and a check retyped from memory at every gate is a check that gets skipped. Two properties it exists to hold: every shard's exit code is read from `bun test` itself rather than from a pipe whose status would overwrite it, and a shard whose summary line cannot be parsed fails the run, because a pass that cannot be counted is not a pass. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + scripts/test-server-sharded.ts | 140 +++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 scripts/test-server-sharded.ts diff --git a/package.json b/package.json index 47f27b238..46f0b2255 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test": "bun test", "typecheck": "bun run --filter '*' typecheck", "test:ci": "bun scripts/test-ci.ts", + "test:server:sharded": "bun scripts/test-server-sharded.ts", "pretest": "bun run generate:app-config", "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts agent-computer/tests/reset-run.test.ts", diff --git a/scripts/test-server-sharded.ts b/scripts/test-server-sharded.ts new file mode 100644 index 000000000..81d5c3ecf --- /dev/null +++ b/scripts/test-server-sharded.ts @@ -0,0 +1,140 @@ +export {}; + +/** + * `server/tests` in shards, because the whole directory in one process no longer runs. + * + * Around a hundred of those files open a module-scope connection pool. PostgreSQL's default + * `max_connections` is 100, so a single `bun test server/tests` exhausts the server's connection + * slots during the import phase and dies with `sorry, too many clients already` BEFORE it reports + * anything. That is a harness ceiling, not a regression, and it is never a reason to change source. + * + * Shards keep the pools-per-process count under the ceiling. The suite is split into fixed-size + * groups of files and each group runs in its own `bun test` invocation, so each process opens its + * own pools and gives them back when it exits. + * + * Why this script exists at all, rather than a remembered shell incantation: a sharded sweep is the + * only thing that has ever caught a test file that went red and stayed red. A targeted run passes, + * `typecheck` passes (`server/tests` is outside `tsc`), a reviewer reads the diff and sees nothing, + * and the branch is red the whole time. The sweep belongs at every quality gate, and a check that + * has to be retyped from memory at every gate is a check that gets skipped. + * + * Two deliberate properties: + * + * - **Every shard's summary line is read, and the exit code comes from `bun test` itself.** Piping + * bun's output through `grep` or `tail` overwrites `$?` with the pipe's status, which is how a + * failing shard reports success. Each shard's stderr is captured and echoed VERBATIM so a failure + * message survives in full for diagnosis. + * - **A shard whose summary cannot be parsed fails the run.** A pass that cannot be counted is not + * a pass; an import-time throw takes its file's tests with it and reports no failure. + * + * Usage: `bun run test:server:sharded`, with `TEST_DATABASE_URL` set for the integration suites. + * `SHARD_SIZE` overrides the files-per-shard count. + */ + +const SHARD_SIZE = Number.parseInt(process.env.SHARD_SIZE ?? "55", 10); + +async function writeStderr(text: string) { + if (!text) return; + if (process.stderr.write(text)) return; + await new Promise((resolve) => process.stderr.once("drain", resolve)); +} + +if (!process.env.TEST_DATABASE_URL?.trim()) { + await writeStderr( + "TEST_DATABASE_URL is empty or unset. The integration suites would each fail to connect, which\n" + + "looks like a hundred broken tests rather than one missing variable. Run this as\n" + + "TEST_DATABASE_URL=postgres://openbot:openbot@localhost:5432/openbot_test bun run test:server:sharded\n", + ); + process.exit(1); +} + +const files = ( + await Array.fromAsync( + new Bun.Glob("server/tests/**/*.test.ts").scan({ cwd: process.cwd() }), + ) +).sort(); + +if (files.length === 0) { + await writeStderr( + "No test files matched server/tests/**/*.test.ts. Refusing to report a pass.\n", + ); + process.exit(1); +} + +const shards: string[][] = []; +for (let index = 0; index < files.length; index += SHARD_SIZE) { + shards.push(files.slice(index, index + SHARD_SIZE)); +} + +// `bun run generate:app-config` once, up front, rather than per shard: the `pretest` hook that +// normally produces it fires for `bun run test`, and these shards invoke `bun test` directly. +const config = Bun.spawn(["bun", "run", "generate:app-config"], { + stdout: "inherit", + stderr: "inherit", +}); +if ((await config.exited) !== 0) { + await writeStderr( + "\nCould not generate the application config, so route imports would fail.\n", + ); + process.exit(1); +} + +await writeStderr( + `\nRunning ${files.length} files in ${shards.length} shards of up to ${SHARD_SIZE}.\n`, +); + +let passed = 0; +let failed = 0; +let skipped = 0; +const unreadable: number[] = []; +const red: number[] = []; + +for (const [index, shard] of shards.entries()) { + await writeStderr( + `\n--- shard ${index + 1}/${shards.length} (${shard.length} files) ---\n`, + ); + + const proc = Bun.spawn(["bun", "test", ...shard], { + stdout: "inherit", + stderr: "pipe", + }); + + // Bun writes its summary to stderr, so it is captured to be parsed, then echoed unmodified. + const stderr = await new Response(proc.stderr).text(); + await writeStderr(stderr); + + // Read the status from the process, never from the text: a parse failure and a test failure are + // different outcomes and both have to be reported. + const exitStatus = await proc.exited; + if (exitStatus !== 0) red.push(index + 1); + + const passMatch = stderr.match(/(\d+) pass/); + const failMatch = stderr.match(/(\d+) fail/); + const skipMatch = stderr.match(/(\d+) skip/); + + if (!passMatch) { + unreadable.push(index + 1); + } else { + passed += Number.parseInt(passMatch[1] as string, 10); + failed += failMatch ? Number.parseInt(failMatch[1] as string, 10) : 0; + skipped += skipMatch ? Number.parseInt(skipMatch[1] as string, 10) : 0; + } +} + +await writeStderr( + `\n=== ${shards.length} shards: ${passed} pass / ${failed} fail / ${skipped} skip ===\n`, +); + +if (red.length > 0) { + await writeStderr(`Shards that exited non-zero: ${red.join(", ")}.\n`); + process.exitCode = 1; +} + +if (unreadable.length > 0) { + await writeStderr( + `Could not read a summary line from shard(s) ${unreadable.join(", ")}. Refusing to report a\n` + + "pass on a run that cannot be counted: the usual cause is a file that threw while being\n" + + "imported, which takes its tests with it and reports nothing.\n", + ); + process.exitCode = 1; +} From 863467016f60343131c7e4a8005fb34686669417 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 15:42:42 +0200 Subject: [PATCH 098/232] fix(app): make provider required on BrokeredAccountRow and accountSentence The row defaulted provider to "composio", a third stated provider fallback in a person-facing copy path: a caller that forgot the prop would tell an Arcade administrator to set COMPOSIO_API_KEY (the class of defect fixed in 3665183b). The type now refuses that call site instead of a reviewer having to notice it. All three app/src call sites already passed provider (admin $key.tsx x2, connected-accounts $key.tsx); no missing call site found. The one test render in brokered-account-row.test.tsx now passes provider="composio" explicitly, and the stale comments describing the default are corrected. Composio renders are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/plugins/brokered-account-row.tsx | 8 ++++---- app/tests/admin-brokered-row-provider.test.tsx | 2 +- app/tests/brokered-account-row.test.tsx | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index ed89d9b15..43bc167a8 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -826,7 +826,7 @@ export function BrokeredAccountRow({ disconnectedReassurance, heading, notice, - provider = "composio", + provider, title, }: { account: BrokeredAccount; @@ -861,10 +861,10 @@ export function BrokeredAccountRow({ /** * Which broker's name the sentences that name one use. A NAME, never a key or a capability. * - * Defaults to Composio so a screen that has not been taught about the second broker renders as it - * always did; a screen on an Arcade deployment passes its own. + * Required, never defaulted: a default would say one broker's name on a deployment running the + * other, so every screen passes the provider it was told. */ - provider?: PluginsProvider; + provider: PluginsProvider; /** * The app's own name, for the sentences that name it. * diff --git a/app/tests/admin-brokered-row-provider.test.tsx b/app/tests/admin-brokered-row-provider.test.tsx index bef224f3d..95872e2a0 100644 --- a/app/tests/admin-brokered-row-provider.test.tsx +++ b/app/tests/admin-brokered-row-provider.test.tsx @@ -23,7 +23,7 @@ import { Route as AdminPluginRoute } from "@/routes/_authed/admin/plugins/$key"; /** * The admin app-detail page's brokered row, on each broker (spec D11). * - * The row is drawn by `BrokeredAccountRow`, which defaults to Composio when not told the provider. + * The row is drawn by `BrokeredAccountRow`, which is told the provider by its caller. * The page already knows the provider, so it must tell the row — in both places it draws one (the * personal row and the Shared-mode team row). Arcade is scanned over text AND markup; Composio's * affected sentence is pinned as an exact string. diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index 1c1811fd8..7ed799035 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -1215,6 +1215,7 @@ function renderRow(account: BrokeredAccount) { account={account} connectedDescription="A Bot granted its tools reads your Gmail as you." disconnectedDescription="No Bot can read this as you." + provider="composio" title="Gmail" />, ); From f2794766ce4b917726fb7bfd57f22aaaffb8627b Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 15:44:09 +0200 Subject: [PATCH 099/232] test(arcade): journey suite uses the shared TEST_POOL and records three coverage facts Switch the suite from its own 4-connection pool to TEST_POOL (2); the stated reason (a transaction plus a reading connection) is exactly what TEST_POOL is sized for, and nothing here runs two completion transactions at once. Green and stable over repeated runs. Docblock now records: why no execute under a Shared identity is driven here (coverage is pinned by gate-grant-is-local.test.ts and shared-gated-mode); that events.length === 1 is a per-actor fact, not a system invariant; and that the two describe blocks are order-coupled. Drops a no-op .map in the Shared index assertion. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/arcade-journey.integration.test.ts | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index b2fd30d54..6ab1e63c8 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -33,6 +33,25 @@ * constructed here, because the journey needs two apps sharing ONE auth provider, which is the * live `Gmail`/`GoogleCalendar`-under-`google` shape the adapter's own docblock describes and which * no single recorded page carries. + * + * WHY NO `execute` UNDER A SHARED APP'S IDENTITY IS DRIVEN HERE: coverage, not scope. The identity + * hop is already pinned by name at `gate-grant-is-local.test.ts:183` ("a vendor-held grant on a + * Shared app still runs as the app's shared identity"), which drives `connectionTokenFor`'s + * `broker && !broker.grantIsLocal -> resolved.ref.vendorUserId` with a deployment ref; that ref + * itself comes from `store.accountRefFor`, which this suite drives four times over. A shared execute + * would add only provider-agnostic gate machinery, owned by `shared-gated-mode.integration.test.ts`. + * If that test is deleted, this is the gap to re-check. + * + * `events.length === 1` IS A PER-ACTOR FACT, NOT A SYSTEM INVARIANT. `ON CONFLICT (actor_id)` proves + * one actor cannot overlap himself. Two DIFFERENT admins pressing Authorize on the same Shared app + * hold two attempt rows, both minting under the one shared vendor identity, and both completions + * run: the connection upsert is idempotent so the row stays single, but the trail takes two + * `mcp.account_connected` rows for one connection. That is consistent with D10 ("at most one + * authorization in flight PER ACTOR") and is arguably the honest trail. + * + * THE TWO `describe` BLOCKS ARE ORDER-COUPLED: the merge-gate describe relies on the first having + * enabled and connected both apps, and fails if run alone with `--test-name-pattern "two + * separately-shared"`. That failure is not a defect. */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; @@ -58,15 +77,10 @@ import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createSharedUseStore } from "../src/plugins/shared-use-store"; import { createPluginStore } from "../src/plugins/store"; import fixture from "./fixtures/arcade-catalogue.json"; -import { testDatabaseUrl } from "./support/database"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; import { testEnvironment } from "./support/environment"; -/* - * FOUR CONNECTIONS rather than the shared `TEST_POOL` of two: the completion transaction holds one - * while the reads that check what it wrote take another, and `support/database.ts` says a file - * wanting a different pool asks for one itself. - */ -const database = createDatabase(testDatabaseUrl(), { max: 4 }); +const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); /** @@ -903,9 +917,7 @@ describe("two separately-shared apps", () => { connections: { serverId: string; holder: string; connected: boolean }[]; }; expect( - connections - .filter((row) => [MAIL_ROW, DIARY_ROW].includes(row.serverId)) - .map((row) => ({ id: row.serverId, ...row, holder: row.holder })), + connections.filter((row) => [MAIL_ROW, DIARY_ROW].includes(row.serverId)), ).toMatchObject([ { serverId: MAIL_ROW, holder: "deployment", connected: false }, { serverId: DIARY_ROW, holder: "deployment", connected: false }, From 352dc5cefc1c3eb5bf00d2b6fb2a57406f5415fe Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 15:44:50 +0200 Subject: [PATCH 100/232] perf(arcade): one auth-provider resolution per toolkit for a whole removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `removeServer` walks one row per PERSON who connected the app, and each `resolveSiblings` resolved the asked toolkit's auth provider plus every app that person holds. On Arcade each of those is a live `tools.list` with no cache, so a removal on a deployment with 200 people holding 5 apps each was on the order of 1,200 serial vendor calls inside one admin request — it times out partway, leaving accounts revoked at the vendor with every local row standing. Amendment 20 refused this shape on the preview path and fixed it with the `vendorUserId` pre-filter; it came back in the per-person dimension. `resolveSiblings` now takes an optional memo of `authProviderOf`, and `removeServer` threads one through the whole walk. The bound it establishes is the number of DISTINCT TOOLKITS across the rows walked, whatever the number of people or connections. A toolkit's auth provider cannot change inside one removal, so this is semantically identical — the same argument the pre-filter made. The memo holds the PROMISE, so concurrent resolutions of one toolkit share one in-flight call. Its lifetime is one call and it is then dropped: deliberately not a process-lifetime cache. On Composio `authProviderOf` is the toolkit name and costs nothing, so no behavioural assertion on either provider can see this; the new test counts the calls on a stub — four people holding the same three apps, sixteen resolutions before and three after. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 63 ++++++++++++++++--- .../tests/arcade-sibling-collateral.test.ts | 49 +++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 9dffa1b89..1e1797de2 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -2947,12 +2947,43 @@ export function createPluginStore(options: PluginStoreOptions) { * candidate. If the asking app itself is unanswered, nothing is resolvable and every other app on * the identity is unanswered with it. */ - async function resolveSiblings(input: { - toolkit: string; - account: AccountRef; - }): Promise<{ siblings: string[]; unanswered: number }> { + async function resolveSiblings( + input: { + toolkit: string; + account: AccountRef; + }, + /** + * ONE CALL'S MEMO OF `authProviderOf`, PASSED IN BY A CALLER THAT RESOLVES OVER MANY ACCOUNTS. + * + * Resolving an auth provider is a live vendor call with no cache of its own on Arcade + * (`arcade-adapter`'s `authProviderOf` lists the toolkit's tools every time); on Composio it is + * the toolkit name and costs nothing. A caller that walks every PERSON who connected an app — + * {@link removeServer} — would otherwise ask the vendor for the same handful of toolkits once + * per person and again once per app that person holds, which is unbounded in the number of + * people and times out partway through a removal that has already revoked at the vendor. + * + * A toolkit's auth provider cannot change inside one removal, so answering the second ask from + * the first is semantically identical — the same argument the identity pre-filter above makes + * for its `where`. The memo holds the PROMISE, so two resolutions of one toolkit share one + * in-flight call rather than racing into two. + * + * ITS LIFETIME IS ONE CALL AND IT IS THEN DISCARDED. Omitted, every resolution is asked for + * fresh, which is what a single-account caller wants; nothing here is a process-lifetime cache, + * which would be a different design with an invalidation question this one does not have. + */ + memo?: Map>, + ): Promise<{ siblings: string[]; unanswered: number }> { if (!broker) return { siblings: [], unanswered: 0 }; - const authProvider = await broker.authProviderOf(input.toolkit); + const vendor = broker; + const authProviderOf = (toolkit: string): Promise => { + if (!memo) return vendor.authProviderOf(toolkit); + const asked = memo.get(toolkit); + if (asked) return asked; + const asking = vendor.authProviderOf(toolkit); + memo.set(toolkit, asking); + return asking; + }; + const authProvider = await authProviderOf(input.toolkit); const held = await database .select({ app: brokeredConnections.app, @@ -2974,7 +3005,7 @@ export function createPluginStore(options: PluginStoreOptions) { const footprints: ConnectionFootprint[] = []; const unanswered = new Set(); for (const row of held) { - const rowProvider = await broker.authProviderOf(row.app); + const rowProvider = await authProviderOf(row.app); if (rowProvider) footprints.push({ ...row, authProvider: rowProvider }); else if (row.app !== input.toolkit) unanswered.add(row.app); } @@ -4018,9 +4049,27 @@ export function createPluginStore(options: PluginStoreOptions) { * set is empty by construction and nothing below differs from what this method always did. */ const endedBesides: { siblings: string[]; unanswered: number }[] = []; + /* + * ONE AUTH-PROVIDER RESOLUTION PER TOOLKIT FOR THE WHOLE REMOVAL, not one per person. + * + * `connected` holds a row per PERSON who connected this app, and each `resolveSiblings` + * above resolves the asked toolkit plus every app that person holds. Unmemoized that is a + * live vendor call per (person × app) on Arcade — a removal on a deployment with two + * hundred people holding five apps each is over a thousand serial calls inside one admin + * request, which times out partway and leaves accounts revoked at the vendor with every + * local row still standing. Memoized, the bound is the number of DISTINCT TOOLKITS across + * those rows, whatever the number of people. + * + * The memo lives exactly as long as this removal and is dropped with it; see + * {@link resolveSiblings} for why one call's worth is semantically free. + */ + const authProviders = new Map>(); for (const connection of connected) { endedBesides.push( - await resolveSiblings({ toolkit, account: accountOf(connection) }), + await resolveSiblings( + { toolkit, account: accountOf(connection) }, + authProviders, + ), ); vendorRevocationRequested.push( broker diff --git a/server/tests/arcade-sibling-collateral.test.ts b/server/tests/arcade-sibling-collateral.test.ts index be7989d44..99c49137f 100644 --- a/server/tests/arcade-sibling-collateral.test.ts +++ b/server/tests/arcade-sibling-collateral.test.ts @@ -216,6 +216,55 @@ describe("removing an app", () => { }); }); + /* + * THE BOUND, AND THE ONLY WAY IT IS VISIBLE. `authProviderOf` is a live `tools.list` per call on + * Arcade and free on Composio (`async (toolkit) => toolkit`), so no behavioural assertion on + * either provider can show the removal asking for it once per PERSON. Counting the calls on a + * stub can: four people holding the same three apps is sixteen resolutions unmemoized, and the + * memo threaded through this one removal brings it to one per DISTINCT TOOLKIT. + */ + test("resolves each toolkit's auth provider once for the whole removal, not once per person", async () => { + const t = run(); + const people = [1, 2, 3, 4].map((i) => `${t.person}-${i}`); + await server(t.gmail, "personal"); + for (const person of people) { + await hold(t.gmail, person); + await hold(t.calendar, person); + await hold(t.slack, person); + } + const asked: string[] = []; + const counting: ConnectedAppBroker = { + ...broker, + authProviderOf: async (toolkit) => { + asked.push(toolkit); + return providerOf(toolkit); + }, + }; + const countingStore = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker: counting, + deploymentId: "acme", + sharedUse: async () => ({ allowed: true }), + }); + + await countingStore.removeServer(t.gmail, "admin"); + + // One ask per distinct toolkit across the rows walked — three — however many people hold them. + expect([...new Set(asked)].sort()).toEqual( + [t.calendar, t.gmail, t.slack].sort(), + ); + expect(asked.length).toBe(3); + // And the removal itself is unchanged: every person's Gmail and Calendar gone, Slack standing. + expect(await rowsFor([t.gmail, t.calendar, t.slack])).toEqual( + people.map(() => t.slack), + ); + }); + test("a revoke that answers false does not delete or audit a sibling as ended", async () => { const t = run(); await server(t.gmail, "personal"); From 070498c425f05c051fa7c460cc3aa22ac51c9709 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 15:45:35 +0200 Subject: [PATCH 101/232] fix(arcade): the grant list shows unauthorized actions without a search visible() hid everything that was not already authorized whenever the search box was empty, so on a fresh account -- every first visit, which is the whole point of the screen -- the section rendered one line: "N more actions not yet authorized. Search to find one." The Authorize button, and with it the consent copy this work exists to put in front of people, could only be reached by guessing a substring of an action name. An affordance nobody can navigate to is not shipped. With an empty term the list now draws the authorized actions first, as before, then the first ten unauthorized ones in the same state-then-name order groupGrants sorts by; the "N more ... search to find one" line stays for whatever is left beyond those ten. Searching with a term behaves exactly as it did. No "recently refused" signal is invented, and the grouping by permission is untouched. The tests said the problem out loud: every test that wanted an Authorize button first typed "Gmail." into the search box, including the acceptance for the consent copy. Those calls are gone, so the tests now drive what a person actually sees, with their assertions unchanged. Added a test that an Authorize affordance is on screen with nothing typed, and one that the tail past ten still waits behind the search and that the search still narrows. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/plugins/grant-list.tsx | 45 +++++-- app/tests/connected-account-grants.test.tsx | 131 +++++++++++++++++--- 2 files changed, 149 insertions(+), 27 deletions(-) diff --git a/app/src/components/plugins/grant-list.tsx b/app/src/components/plugins/grant-list.tsx index 7ded46b32..c9a7b650e 100644 --- a/app/src/components/plugins/grant-list.tsx +++ b/app/src/components/plugins/grant-list.tsx @@ -30,10 +30,25 @@ import { * not connected and telling somebody to authorize what they already authorized sends them round * twice. * - * NO ORDERING BY "RECENTLY REFUSED". Authorized actions come first and everything else sits behind - * the search; there is no per-person, per-app refusal read on any route this page can call, and - * inventing a column to order on would be a claim this page could not back. + * NO ORDERING BY "RECENTLY REFUSED". Authorized actions come first, then the unauthorized ones in + * state order, and only the tail beyond `UNAUTHORIZED_SHOWN` sits behind the search; there is no + * per-person, per-app refusal read on any route this page can call, and inventing a column to order + * on would be a claim this page could not back. */ + +/** How many unauthorized actions are drawn before the rest are left to the search. */ +const UNAUTHORIZED_SHOWN = 10; + +/** + * The order the unauthorized tail is picked in, matching `groupGrants`'s own ranking so what is + * drawn with no search term is the front of the same list the groups are sorted by. + */ +const ORDER: Record = { + authorized: 0, + "needs-consent": 1, + "needs-operator": 2, + unknown: 3, +}; export function GrantList({ serverId, appNames, @@ -113,12 +128,26 @@ export function GrantList({ } const term = search.trim().toLowerCase(); - const visible = (grant: GrantState) => + const unauthorized = all + .filter((grant) => grant.state !== "authorized") + .sort( + (left, right) => + ORDER[left.state] - ORDER[right.state] || + left.action.localeCompare(right.action), + ); + /* With no search term the first unauthorized actions are drawn as well: an Authorize that only + appears once somebody guesses a substring is an Authorize nobody on a fresh account can reach, + and a fresh account is every first visit. Beyond those, the count below still stands. */ + const shown = term.length > 0 - ? grant.action.toLowerCase().includes(term) - : grant.state === "authorized"; - const hidden = term.length > 0 ? 0 : all.filter((g) => !visible(g)).length; - const groups: GrantGroup[] = groupGrants(all.filter(visible)); + ? all.filter((grant) => grant.action.toLowerCase().includes(term)) + : [ + ...all.filter((grant) => grant.state === "authorized"), + ...unauthorized.slice(0, UNAUTHORIZED_SHOWN), + ]; + const hidden = + term.length > 0 ? 0 : Math.max(unauthorized.length - UNAUTHORIZED_SHOWN, 0); + const groups: GrantGroup[] = groupGrants(shown); const consentActions = consentActionsFrom(all); return ( diff --git a/app/tests/connected-account-grants.test.tsx b/app/tests/connected-account-grants.test.tsx index 9d93cf1d2..6c7386bf0 100644 --- a/app/tests/connected-account-grants.test.tsx +++ b/app/tests/connected-account-grants.test.tsx @@ -106,6 +106,13 @@ type Setup = { siblings?: { siblings: string[]; unanswered: number }; /** Whether the siblings read answers 500. */ siblingsFail?: boolean; + /** + * Whether the deployment's broker authorizes per action, the capability the grant list is gated + * on. Defaults to what the named provider's broker declares today, so a case that says nothing + * about it gets the deployment it has always meant; set it against the name to build the + * deployment where the two DISAGREE. + */ + authorizesPerAction?: boolean; grants?: GrantState[]; /** Whether the grants read answers 500. */ accountMode?: "shared" | null; @@ -125,6 +132,8 @@ function install(setup: Setup) { botsMayCallBack: true, redirectUri: null, composioConfigured: setup.configured ?? true, + authorizesPerAction: + setup.authorizesPerAction ?? setup.provider === "arcade", provider: setup.provider, }; const json = (body: unknown, status = 200) => @@ -251,17 +260,26 @@ function row(view: ReturnType, action: string): HTMLElement { return found; } -async function showAll(view: ReturnType) { - await view.findByLabelText("Search actions"); - await userEvent.type(view.getByLabelText("Search actions"), "Gmail."); -} +// ---- 0: reachable without typing anything ---- + +test("with no search term, Authorize for an unauthorized action is already on screen", async () => { + install({ provider: "arcade", connected: true, grants: GMAIL }); + const view = renderScreen(); + // Nothing is typed: this is the first visit, and the affordance must be here anyway. + await waitFor(() => row(view, "Gmail.SendEmail")); + expect( + [...row(view, "Gmail.SendEmail").querySelectorAll("button")].map( + (b) => b.textContent, + ), + ).toEqual(["Authorize"]); + expect(view.getByLabelText("Search actions")).toHaveProperty("value", ""); +}); // ---- 1: each action renders by its GrantState ---- test("Authorize appears for needs-consent and for nothing else", async () => { install({ provider: "arcade", connected: true, grants: GMAIL }); const view = renderScreen(); - await showAll(view); await waitFor(() => row(view, "Gmail.SendEmail")); const buttons = (action: string) => [...row(view, action).querySelectorAll("button")].map((b) => b.textContent); @@ -284,7 +302,6 @@ test("needs-operator names the missing secret, says an administrator sets it, an ], }); const view = renderScreen(); - await showAll(view); await waitFor(() => row(view, "Gmail.Setup")); const text = row(view, "Gmail.Setup").textContent ?? ""; expect(text).toContain("GOOGLE_CLIENT_SECRET"); @@ -311,7 +328,6 @@ test("unknown offers Retry and never a speculative Authorize", async () => { ], }); const view = renderScreen(); - await showAll(view); await waitFor(() => row(view, "Gmail.Flaky")); const labels = [...row(view, "Gmail.Flaky").querySelectorAll("button")].map( (b) => b.textContent, @@ -348,7 +364,6 @@ test("the route's synthetic toolkit-named unknown reads as could not read and ne test("actions are grouped by the permission they require", async () => { install({ provider: "arcade", connected: true, grants: GMAIL }); const view = renderScreen(); - await showAll(view); await waitFor(() => row(view, "Gmail.SendEmail")); const group = (scope: string) => view.container.querySelector(`[data-grant-group="${scope}"]`); @@ -376,7 +391,6 @@ test("an all-or-nothing app renders as ONE group containing everything", async ( ], }); const view = renderScreen(); - await userEvent.type(await view.findByLabelText("Search actions"), "Github."); await waitFor(() => row(view, "Github.B")); const groups = view.container.querySelectorAll("[data-grant-group]"); expect(groups.length).toBe(1); @@ -386,22 +400,69 @@ test("an all-or-nothing app renders as ONE group containing everything", async ( // ---- 3: ordering ---- -test("authorized actions are listed first and the rest wait behind a search", async () => { - install({ provider: "arcade", connected: true, grants: GMAIL }); +test("authorized actions are listed first, and the unauthorized ones follow with nothing typed", async () => { + install({ + provider: "arcade", + connected: true, + grants: [ + { action: "Github.B", state: "needs-consent", scopes: [] }, + { action: "Github.C", state: "authorized", scopes: [] }, + { action: "Github.A", state: "authorized", scopes: [] }, + { action: "Github.D", state: "unknown", reason: "Try again." }, + ], + }); + const view = renderScreen(); + await waitFor(() => row(view, "Github.B")); + expect( + [...view.container.querySelectorAll("[data-action]")].map((element) => + element.getAttribute("data-action"), + ), + ).toEqual(["Github.A", "Github.C", "Github.B", "Github.D"]); + expect(view.container.textContent).not.toContain( + "more actions not yet authorized", + ); +}); + +test("past the first ten unauthorized actions the rest wait behind the search", async () => { + const many: GrantState[] = [ + { + action: "Gmail.ListEmails", + state: "authorized", + scopes: ["gmail.readonly"], + }, + ...Array.from({ length: 12 }, (_, index): GrantState => { + const name = String.fromCharCode(97 + index); + return { + action: `Gmail.Send${name}`, + state: "needs-consent", + scopes: ["gmail.send"], + }; + }), + ]; + install({ provider: "arcade", connected: true, grants: many }); const view = renderScreen(); await view.findByText(/more actions not yet authorized/); expect(row(view, "Gmail.ListEmails")).toBeTruthy(); + expect(row(view, "Gmail.Senda")).toBeTruthy(); + expect(row(view, "Gmail.Sendj")).toBeTruthy(); + expect( + view.container.querySelector('[data-action="Gmail.Sendk"]'), + ).toBeNull(); expect( - view.container.querySelector('[data-action="Gmail.SendEmail"]'), + view.container.querySelector('[data-action="Gmail.Sendl"]'), ).toBeNull(); expect(view.container.textContent).toContain( - "3 more actions not yet authorized", + "2 more actions not yet authorized", ); - await userEvent.type(view.getByLabelText("Search actions"), "send"); - await waitFor(() => row(view, "Gmail.SendEmail")); + // The search still narrows to exactly what matches, and reaches the tail. + await userEvent.type(view.getByLabelText("Search actions"), "sendk"); + await waitFor(() => row(view, "Gmail.Sendk")); expect( view.container.querySelector('[data-action="Gmail.ListEmails"]'), ).toBeNull(); + expect( + view.container.querySelector('[data-action="Gmail.Senda"]'), + ).toBeNull(); }); // ---- 4: Shared ---- @@ -415,7 +476,6 @@ test("a Shared app shows state to a member but Authorize only to an administrato grants: GMAIL, }); const member = renderScreen(); - await showAll(member); await waitFor(() => row(member, "Gmail.SendEmail")); expect(row(member, "Gmail.ListEmails").textContent).toContain("Authorized"); expect(member.queryByText("Authorize")).toBeNull(); @@ -429,7 +489,6 @@ test("a Shared app shows state to a member but Authorize only to an administrato grants: GMAIL, }); const admin = renderScreen(); - await showAll(admin); await waitFor(() => row(admin, "Gmail.SendEmail")); await waitFor(() => expect(admin.queryByText("Authorize")).not.toBeNull()); }); @@ -439,7 +498,6 @@ test("a Shared app shows state to a member but Authorize only to an administrato test("pressing Authorize renders consentCopy's sentence and redirects nowhere yet", async () => { install({ provider: "arcade", connected: true, grants: GMAIL }); const view = renderScreen(); - await showAll(view); await waitFor(() => row(view, "Gmail.SendEmail")); fireEvent.click(view.getByText("Authorize")); const expected = consentCopy("Gmail.SendEmail", [ @@ -508,7 +566,6 @@ test("on Arcade the grant list says nothing of Composio, in text or attributes", siblings: { siblings: ["googledocs"], unanswered: 0 }, }); const view = renderScreen("?action=Gmail.ListEmails"); - await showAll(view); await waitFor(() => row(view, "Gmail.SendEmail")); fireEvent.click(view.getByText("Authorize")); await view.findByRole("group", { name: /Before you authorize/ }); @@ -523,3 +580,39 @@ test("a Composio deployment draws no grant list and never reads the grants", asy expect(view.container.querySelector("[data-action]")).toBeNull(); expect(view.queryByLabelText("Search actions")).toBeNull(); }); + +/* + * ---- the gate is the CAPABILITY, not the name ---- + * + * The case where the two DISAGREE, which is the whole reason the screen stopped comparing names: a + * deployment whose provider is named arcade but whose broker declares no per-action authorization + * flow. `/servers/:id/authorize` refuses such a deployment with 503, so a list drawn here would be + * a screenful of buttons that cannot work. The name says draw it; the capability says do not; the + * capability is what the route behind the buttons reads. + */ +test("a broker named arcade that authorizes no action draws no grant list", async () => { + install({ + provider: "arcade", + authorizesPerAction: false, + connected: true, + grants: GMAIL, + }); + const view = renderScreen(); + await view.findByText("Disconnect"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(view.container.querySelector("[data-action]")).toBeNull(); + expect(view.queryByLabelText("Search actions")).toBeNull(); +}); + +/* And the mirror: the capability alone is enough, with no name read anywhere on the way. */ +test("a broker that authorizes per action draws the grant list", async () => { + install({ + provider: "arcade", + authorizesPerAction: true, + connected: true, + grants: GMAIL, + }); + const view = renderScreen(); + await waitFor(() => row(view, "Gmail.SendEmail")); + expect(view.getByLabelText("Search actions")).toBeTruthy(); +}); From feea10fc428ceb5f9be696d1e1eac32dd0a713c2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 15:47:56 +0200 Subject: [PATCH 102/232] test(arcade): the live suite, written and never run Behind OPENBOT_LIVE_ARCADE, with test:live-arcade refusing to exit 0 on an empty key. Carries the original experiment and the four merge-gate questions as named cases. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + server/tests/arcade-live.test.ts | 586 +++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 server/tests/arcade-live.test.ts diff --git a/package.json b/package.json index 46f0b2255..240adafed 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts agent-computer/tests/reset-run.test.ts", "test:live-composio": "bun -e \"if (!process.env.COMPOSIO_API_KEY?.trim()) { console.error('COMPOSIO_API_KEY is empty or unset, so every case in this suite would skip and this command would still exit 0. Run it as COMPOSIO_API_KEY=... bun run test:live-composio.'); process.exit(1) }\" && OPENBOT_LIVE_COMPOSIO=1 bun test server/tests/composio-live.test.ts", + "test:live-arcade": "bun -e \"if (!process.env.ARCADE_API_KEY?.trim()) { console.error('ARCADE_API_KEY is empty or unset, so every case in this suite would skip and this command would still exit 0. Run it as ARCADE_API_KEY=... bun run test:live-arcade.'); process.exit(1) }\" && OPENBOT_LIVE_ARCADE=1 bun test server/tests/arcade-live.test.ts", "diagram": "bun scripts/architecture-diagram.ts", "composio:smoke": "bun scripts/composio-smoke.ts", "mock:knowledge": "bun scripts/mock-knowledge-mcp.ts" diff --git a/server/tests/arcade-live.test.ts b/server/tests/arcade-live.test.ts new file mode 100644 index 000000000..85b258395 --- /dev/null +++ b/server/tests/arcade-live.test.ts @@ -0,0 +1,586 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import Arcade, { APIError } from "@arcadeai/arcadejs"; +import { classify } from "../src/plugins/arcade"; +import { + createArcadeClient, + normalizeArcadeError, +} from "../src/plugins/arcade-adapter"; +import { mintDeploymentVendorUserId } from "../src/plugins/shared-accounts"; + +/** + * The real Arcade, so the shapes this transport is written against are the shapes it gets. + * + * WRITTEN, AND NEVER RUN BY WHOEVER WROTE IT. Nothing in this branch had contacted Arcade when this + * file was authored; a live run is a separate, human-authorised step. Every wire fact below was + * measured once by a throwaway harness and is repeated here so it is repeatable rather than + * remembered. + * + * WHY THIS EXISTS AT ALL. A stub asserts what its author believed; this asserts what the vendor does. + * `composio-live.test.ts` is the pattern, and its argument carries over whole. + * + * WRITTEN AGAINST THE VENDOR'S TYPES, WITH NO `as never`. `server/tsconfig.json` does not include + * `tests`, so nothing in the build type-checks this file, and a cast here would erase the only place + * the real `@arcadeai/arcadejs` surface is named. Every call is typed by the SDK itself; where a + * value is narrowed it is narrowed by a runtime check that says what was missing, never by a cast + * asserting it was there. + * + * SKIPPED WITHOUT A KEY. Run it deliberately, through the package script, which refuses to exit 0 + * when the key is empty (a skipped suite would otherwise look like a passed one): + * + * ARCADE_API_KEY=... bun run test:live-arcade + * + * WHAT YOU MUST SET UP FIRST (the consent cases need a person and a browser) + * - The Arcade project's CUSTOM USER VERIFIER URL must point at this machine: + * `http://localhost:${ARCADE_LIVE_VERIFIER_PORT:-8765}/`. The suite listens there, as the stand-in + * for the product's own verifier route, and reads what Arcade sends it. Restore the setting after. + * - You will be shown ONE authorization URL on stderr and must open it and consent. It is a bearer + * capability for a throwaway user id; it is printed to you, the caller, and goes nowhere else. + * - Optional: ARCADE_LIVE_TOOLKIT (default `Github`; the toolkit whose blast radius is measured and + * whose action is consented to), ARCADE_LIVE_ACTION (the qualified action to mint for; default the + * first listed read-only action), ARCADE_LIVE_CONSENT_TIMEOUT_S (default 300). + * + * WHAT THIS CREATES AND MUTATES IN THE ARCADE PROJECT THE KEY BELONGS TO (this is PRODUCTION if the + * key is) + * - ONE authorization attempt (`ar_...`) per successful mint, for ONE throwaway user id of the shape + * `openbot-deployment:live-arcade:`. Several mints are attempted: the `next_uri` cases are + * expected to be refused, and if one is ever accepted it creates an attempt too. + * - ONE completed CONSENT by YOU, at the third party (Github by default), and so ONE user-connection + * (`ac_...`) under that throwaway user id in Arcade. It is the only durable write. + * - Everything else is a read: tool listings, user-connection listings, `auth.status`. + * - A deliberate wrong-user `confirm_user` (the `user_mismatch` case); it is expected to be refused. + * + * WHAT IT DELETES AFTERWARDS: every user-connection for that one throwaway user id, in the delete case + * and again in `afterAll` if an earlier case failed first. It never lists or deletes anyone else's. + * + * WHAT IT CANNOT CLEAN UP, and you must: + * - The grant at the third party. Deleting an Arcade connection does not necessarily revoke your + * consent there; remove the OAuth app authorization from the provider's own settings page. + * - The `ar_...` attempt records. The SDK has no call to delete one; they expire on their own. + * - If the run is killed before `afterAll`, the connection survives. Its user id is printed first so + * it can be found and deleted in the Arcade dashboard. + * + * NEVER PRINTED: anything from an `auth.status` response beyond `status`, `user_id` and `scopes`. The + * response carries `context.token`, a live provider access token, and the person's profile. This file + * reads three fields out of it and drops the object, as the adapter does. + * + * NEVER NORMALIZED: toolkit and action names keep Arcade's own case throughout. + * + * THE FOUR MERGE-GATE QUESTIONS are named cases, `GATE 1` to `GATE 4`. Each prints one line starting + * `GATE n` and says in a comment what each outcome means. A human reads those lines. + * + * THREE FENCED FACTS THAT LOOK LIKE BUGS AND ARE MEASUREMENTS; this suite proves them, never "fixes" + * them: (1) `next_uri` is never sent to `tools.authorize`; (2) `admin.userConnections.delete` takes + * `connection_id` (`ac_...`), not the `id` (`at_...`) listed first; (3) `listTools` reads + * `requirements.authorization.oauth2.scopes`, never `requirements.met`. + */ +const key = process.env.ARCADE_API_KEY?.trim(); +const live = process.env.OPENBOT_LIVE_ARCADE === "1" && Boolean(key); + +const PORT = Number(process.env.ARCADE_LIVE_VERIFIER_PORT ?? "8765"); +const TOOLKIT = process.env.ARCADE_LIVE_TOOLKIT?.trim() || "Github"; +const CONSENT_TIMEOUT_S = Number( + process.env.ARCADE_LIVE_CONSENT_TIMEOUT_S ?? "300", +); + +/** The Google family: two toolkits that, on Arcade, are expected to share one auth provider. */ +const GOOGLE_TOOLKITS = ["Gmail", "GoogleCalendar"]; + +/** One throwaway person, so nothing here can touch an account that belongs to somebody. */ +const USER = mintDeploymentVendorUserId("live-arcade"); +const ACCOUNT = { holder: "deployment" as const, vendorUserId: USER }; +/** A user id that is not the attempt's, for the `user_mismatch` case. */ +const OTHER_USER = mintDeploymentVendorUserId("live-arcade-other"); + +const say = (line: string) => process.stdout.write(`${line}\n`); + +/** Constructed lazily: Bun evaluates a skipped describe's body, and the constructor throws keyless. */ +const arcade = () => new Arcade({ apiKey: key }); + +/** A precondition that was not met is stated, not asserted into existence. */ +function need(value: T | null | undefined, what: string): T { + if (value === null || value === undefined || value === "") { + throw new Error( + `Precondition not met: ${what}. Nothing after this point was exercised.`, + ); + } + return value; +} + +/** Every item of a toolkit's listing, for one user, paged by the SDK. */ +async function listToolkit(client: Arcade, toolkit: string, userId?: string) { + const items: Arcade.ToolDefinition[] = []; + for await (const item of client.tools.list({ + toolkit, + user_id: userId, + limit: 100, + })) { + items.push(item); + } + return items; +} + +/** `met` ACROSS A WHOLE TOOLKIT: what was granted, as against what was asked for. */ +const metCount = (items: Arcade.ToolDefinition[]) => + items.filter((item) => item.requirements?.met === true).length; + +const providersOf = (items: Arcade.ToolDefinition[]) => [ + ...new Set( + items + .map((item) => item.requirements?.authorization?.provider_id) + .filter((p): p is string => typeof p === "string"), + ), +]; + +/** The one failure an SDK call is allowed to be expected to throw. */ +async function failureOf(call: () => Promise): Promise { + let thrown: unknown; + try { + await call(); + } catch (error) { + thrown = error; + } + if (thrown === undefined) { + throw new Error( + "The call was expected to be refused and it succeeded. Whatever it created now exists in the project.", + ); + } + if (!(thrown instanceof APIError)) { + throw new Error( + `The call failed, but not with an SDK APIError (${thrown instanceof Error ? thrown.name : typeof thrown}), so no wire answer was read.`, + ); + } + return thrown; +} + +/** The state the consent sequence hands from one case to the next. */ +const run: { + action?: string; + baselineMet?: number; + total?: number; + authorizationId?: string; + url?: string; + callbackKeys?: string[]; + flowId?: string; + connectionId?: string; + rowId?: string; + grantedScopes?: string[]; +} = {}; + +describe.skipIf(!live)("Arcade, for real", () => { + afterAll(async () => { + if (!live) return; + // Best effort, and only ever this run's own user id. + const client = arcade(); + for await (const row of client.admin.userConnections.list({ + user_id: USER, + })) { + if (row.connection_id) { + await client.admin.userConnections + .delete(row.connection_id) + .catch(() => {}); + } + } + }); + + test("the throwaway user id is printed before anything is created", () => { + say(`ARCADE LIVE user id for this run: ${USER}`); + say( + `ARCADE LIVE toolkit measured: ${TOOLKIT}; verifier listens on :${PORT}`, + ); + expect(Number.isInteger(PORT)).toBe(true); + }); + + /** + * GATE 1 — WHAT `provider_id` ACTUALLY MEANS ON A GOOGLE-FAMILY TOOLKIT. + * + * Pure read; nothing created. Prints, for `Gmail` and `GoogleCalendar`, the distinct provider ids + * their listings name. + * - ONE shared id, DIFFERENT from both toolkit names (e.g. `google`): `provider_id` is an + * auth-provider configuration. D26's sibling set is non-empty in production: revoking Gmail also + * removes the Calendar connection. + * - Each toolkit names a provider EQUAL to its own name: `provider_id` is effectively the toolkit. + * The sibling set is empty in production. + * The design is safe under both readings (it reads the field, never derives it); the outcome only + * decides whether D26's sibling code is ever exercised. No offline fixture names a toolkit's + * provider differently from the toolkit, which is why only this can tell. + */ + test("GATE 1: what provider_id means on Gmail and GoogleCalendar", async () => { + const client = arcade(); + const seen: Record = {}; + for (const toolkit of GOOGLE_TOOLKITS) { + const items = await listToolkit(client, toolkit); + need( + items.length > 0 ? items : null, + `Arcade listed no ${toolkit} action, so its provider could not be read`, + ); + seen[toolkit] = providersOf(items); + } + const all = new Set(Object.values(seen).flat()); + const sharedAndDistinct = + all.size === 1 && !GOOGLE_TOOLKITS.some((name) => all.has(name)); + const equalToToolkit = GOOGLE_TOOLKITS.every((name) => + seen[name]?.every((p) => p === name), + ); + say( + `GATE 1 provider ids by toolkit: ${JSON.stringify(seen)} -> ` + + (sharedAndDistinct + ? "ONE shared provider, named differently from the toolkits: D26's sibling set IS non-empty in production" + : equalToToolkit + ? "each toolkit names a provider equal to itself: D26's sibling set is empty in production" + : "MIXED: read the ids above and decide by hand"), + ); + // Whichever reading holds, every toolkit must name exactly one provider, or authProviderOf + // answers null and every connection under it reads as absent. + for (const toolkit of GOOGLE_TOOLKITS) { + expect(seen[toolkit]?.length).toBe(1); + } + }); + + test("the action to consent to is read from the listing, and the baseline is clean", async () => { + const client = arcade(); + const items = await listToolkit(client, TOOLKIT, USER); + run.total = items.length; + need(items.length > 0 ? items : null, `Arcade listed no ${TOOLKIT} action`); + run.action = + process.env.ARCADE_LIVE_ACTION?.trim() || + items.find((item) => { + const verdict = classify(item.metadata); + return verdict.effect === "read" && !verdict.destructive; + })?.qualified_name; + need( + run.action, + `${TOOLKIT} lists no read-only action and ARCADE_LIVE_ACTION is unset`, + ); + // A fresh user id has granted nothing. If anything already reads `met`, the "before" is not a + // baseline and the "after" would measure nothing. + run.baselineMet = metCount(items); + say( + `BLAST RADIUS baseline for ${TOOLKIT}: met ${run.baselineMet} of ${items.length} (action to mint: ${run.action})`, + ); + expect(run.baselineMet).toBe(0); + }); + + /** + * FENCED FACT 1: `next_uri` IS REFUSED FOR EVERY VALUE, so the adapter never sends it. + * Every value must answer 500 `invalid next URI`. If ANY is accepted, the fence is stale: report + * it, do not start sending one. + */ + test("next_uri is refused for every value", async () => { + const client = arcade(); + const action = need(run.action, "no action was chosen"); + const values = [ + "https://app.example.com/settings/connected-accounts", + `http://localhost:${PORT}/`, + "https://cloud.arcade.dev", + "http://localhost:3000/", + "/relative/path", + ]; + for (const next_uri of values) { + const refusal = await failureOf(() => + client.tools.authorize({ + tool_name: action, + user_id: USER, + next_uri, + }), + ); + expect([next_uri, refusal.status]).toEqual([next_uri, 500]); + expect(normalizeArcadeError(refusal)?.message ?? "").toMatch( + /invalid next uri/i, + ); + } + }); + + /** + * THE CONSENT: mint through OUR adapter, wait for the callback at a local stand-in for the + * verifier, and keep only the flow id. A person is needed here. + */ + test( + "a mint through the adapter, and a callback carrying flow_id ONLY", + async () => { + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + const action = need(run.action, "no action was chosen"); + + let resolveCallback: (url: URL) => void = () => {}; + const callback = new Promise((resolve) => { + resolveCallback = resolve; + }); + const server = Bun.serve({ + port: PORT, + fetch(request) { + resolveCallback(new URL(request.url)); + return new Response("Consent received. You can close this tab."); + }, + }); + try { + const minted = await broker.authorize({ + account: ACCOUNT, + toolkit: TOOLKIT, + action, + returnUrl: `http://localhost:${PORT}/`, + }); + run.authorizationId = minted.authorizationId ?? undefined; + expect(run.authorizationId).toMatch(/^ar_/); + // To the caller, on stderr, once. Never to a log, an audit row or a `cause`. + process.stderr.write( + `\nOPEN THIS URL AND CONSENT (throwaway user ${USER}):\n${minted.redirectUrl}\n\n`, + ); + + const timeout = new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `No callback reached localhost:${PORT} in ${CONSENT_TIMEOUT_S}s. Is the project's custom verifier URL pointed at it, and was the consent completed?`, + ), + ), + CONSENT_TIMEOUT_S * 1000, + ), + ); + const arrived = await Promise.race([callback, timeout]); + run.callbackKeys = [...arrived.searchParams.keys()]; + run.flowId = arrived.searchParams.get("flow_id") ?? undefined; + say( + `CALLBACK query parameters received: ${run.callbackKeys.join(",")}`, + ); + // `flow_id` and nothing else: no user id, no auth id, no state to compare against. + expect(run.callbackKeys).toEqual(["flow_id"]); + need(run.flowId, "the callback carried no flow_id"); + } finally { + server.stop(true); + } + }, + (CONSENT_TIMEOUT_S + 30) * 1000, + ); + + /** + * GATE 2 — THE `user_mismatch` WIRE SHAPE, currently ASSUMED (the SDK declares no error shape). + * + * Confirms with a user id that is NOT the attempt's, first raw (printing status, name, message + * from the error body) and then through `confirmReturn`. + * - `confirmReturn` answers `{ mismatch: true }`: the assumed shape is right. + * - `confirmReturn` THROWS: the shape is wrong (see the printed name). UX degradation, not a + * security hole: D24's `auth_id` comparison still refuses the wrong person. Fix + * `normalizeArcadeError`'s match, not the comparison. + * - Neither refuses: Arcade accepts any user for a flow; D24's `auth_id` comparison is then the + * ONLY thing binding the callback to its attempt. Say so at the gate. + * Run BEFORE the correct confirm; if a mismatch burns the flow id the next case says so. + */ + test("GATE 2: the user_mismatch wire shape", async () => { + const flowId = need(run.flowId, "no flow_id was captured"); + const client = arcade(); + let raw = "ACCEPTED (no refusal)"; + try { + await client.auth.confirmUser({ flow_id: flowId, user_id: OTHER_USER }); + } catch (error) { + const failure = normalizeArcadeError(error); + raw = `status=${failure?.status} name=${failure?.name} message=${failure?.message}`; + } + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + const flow = need( + broker.authorizations, + "the adapter has no authorizations", + ); + let through = ""; + try { + const answer = await flow.confirmReturn({ + handle: flowId, + vendorUserId: OTHER_USER, + }); + through = + "authorizationId" in answer + ? `ACCEPTED, authorization ${answer.authorizationId}` + : "{ mismatch: true }"; + } catch (error) { + through = `THREW ${error instanceof Error ? error.name : typeof error}`; + } + say(`GATE 2 raw confirm_user as wrong user: ${raw}`); + say(`GATE 2 confirmReturn as wrong user: ${through}`); + expect(through).toBe("{ mismatch: true }"); + }); + + test("confirm_user returns the mint's own ar_ id as auth_id", async () => { + const flowId = need( + run.flowId, + "no flow_id was captured (if GATE 2 ran, a mismatch may have consumed it)", + ); + const answer = await arcade().auth.confirmUser({ + flow_id: flowId, + user_id: USER, + }); + expect(answer.auth_id).toBe(need(run.authorizationId, "no mint id")); + expect(answer.auth_id).toMatch(/^ar_/); + }); + + test("auth.status reaches completed and names the attempt's user_id", async () => { + const id = need(run.authorizationId, "no mint id"); + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + const flow = need( + broker.authorizations, + "the adapter has no authorizations", + ); + let last = ""; + // Only the three narrowed fields are ever held; the token-carrying response never leaves the adapter. + for (let i = 0; i < 6; i += 1) { + const seen = await flow.status(id); + last = seen.status; + if (seen.status === "completed") { + expect(seen.userId).toBe(USER); + run.grantedScopes = seen.scopes; + say( + `AUTH STATUS completed; granted scopes: ${seen.scopes.join(" ") || "(none listed)"}`, + ); + return; + } + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error( + `auth.status never reached completed; last status was ${last}.`, + ); + }); + + /** + * THE BLAST-RADIUS MEASUREMENT. `met` across the WHOLE toolkit, from the clean baseline taken above. + * Distinct REQUESTED scopes are NOT a substitute: they show what was asked for and never what was + * granted. One read-only consent that flips every action to `met: true` means a single approval + * granted the whole toolkit (Github measured 43 of 43). Prints `met before -> after of total`. + */ + test("blast radius: met across the whole toolkit, before and after one consent", async () => { + const items = await listToolkit(arcade(), TOOLKIT, USER); + const after = metCount(items); + say( + `BLAST RADIUS ${TOOLKIT}: met ${run.baselineMet} -> ${after} of ${items.length} after consenting to ONE action (${run.action})`, + ); + expect(after).toBeGreaterThan(need(run.baselineMet ?? 0, "no baseline")); + }); + + /** + * GATE 3 — PER-ACTION ISOLATION THROUGH OUR VERIFIER, on a scoped provider (the spec's "Unproven"). + * + * Reads what the adapter reports for every action of the toolkit after the one consent. An action + * is a VIOLATION when it reports `authorized` although it asks for scopes the consent never granted. + * - ZERO violations: per-action grants isolate; consenting to one action does not authorize a + * broader one. + * - Any violation: the grant is wider than the action it was minted for. The consent screen + * under-describes it, and the per-action design needs the blast-radius wording instead. + * On a toolkit whose actions carry no scopes (Github), isolation cannot be measured and this says + * so, which is a result: use ARCADE_LIVE_TOOLKIT=Gmail for a scoped provider. + */ + test("GATE 3: per-action isolation through our verifier", async () => { + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + const grants = await broker.grantsFor({ + account: ACCOUNT, + toolkit: TOOLKIT, + }); + const granted = new Set(run.grantedScopes ?? []); + const scoped = grants.filter( + (g) => g.state === "authorized" || g.state === "needs-consent", + ); + const violations = scoped.filter( + (g) => + g.state === "authorized" && + g.scopes.length > 0 && + g.scopes.some((scope) => !granted.has(scope)), + ); + const withScopes = scoped.filter((g) => g.scopes.length > 0).length; + say( + `GATE 3 ${TOOLKIT}: ${grants.filter((g) => g.state === "authorized").length} of ${grants.length} actions authorized; ${withScopes} carry scopes; ` + + `${violations.length} authorized beyond the granted scopes` + + (withScopes === 0 + ? " -> NOT MEASURABLE on this toolkit (no action carries scopes); rerun on a scoped one" + : violations.length === 0 + ? " -> isolation HOLDS" + : ` -> isolation FAILS: ${violations.map((v) => v.action).join(", ")}`), + ); + expect(violations).toEqual([]); + }); + + /** + * GATE 4 — THE SERVER-SIDE `provider_id` LIST FILTER'S CASE HANDLING (6.2 matches client-side + * because this is unverified). Lists this user's connections filtered by the provider id exactly, + * upper-cased and lower-cased, and prints the row counts. + * - exact finds the row, the others do not: the server filter is CASE-SENSITIVE; the client-side + * fold is required and must stay. + * - all three find it: case-INSENSITIVE; the fold is redundant but harmless. + * - exact finds nothing: the filter does not take this provider id at all; do not trust it. + * Also checks, through the adapter, that the connection is seen and named under the toolkit's + * provider. + */ + test("GATE 4: provider_id filter case handling, and the adapter sees the connection", async () => { + const client = arcade(); + const items = await listToolkit(client, TOOLKIT); + const provider = need( + providersOf(items)[0], + `${TOOLKIT} names no provider_id`, + ); + const count = async (provider_id: string) => { + let n = 0; + for await (const row of client.admin.userConnections.list({ + user_id: USER, + provider_id, + })) { + void row; + n += 1; + } + return n; + }; + const exact = await count(provider); + const upper = await count(provider.toUpperCase()); + const lower = await count(provider.toLowerCase()); + say( + `GATE 4 provider "${provider}": exact ${exact}, upper ${upper}, lower ${lower} -> ` + + (exact === 0 + ? "filter does not take this provider id" + : upper === exact && lower === exact + ? "server filter is CASE-INSENSITIVE" + : "server filter is CASE-SENSITIVE (keep the client-side fold)"), + ); + expect(exact).toBeGreaterThan(0); + + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + expect(await broker.authProviderOf?.(TOOLKIT)).toBe(provider); + expect( + await broker.isConnected({ account: ACCOUNT, toolkit: TOOLKIT }), + ).toBe(true); + }); + + /** + * FENCED FACT 2: delete takes `connection_id` (`ac_...`); the `id` (`at_...`) listed first answers + * 500. Run against this run's one throwaway connection, then it is really deleted. + */ + test("delete takes connection_id, and the at_ id answers 500", async () => { + const client = arcade(); + const rows = []; + for await (const row of client.admin.userConnections.list({ + user_id: USER, + })) { + rows.push(row); + } + const row = need(rows[0], "the throwaway user holds no connection"); + run.connectionId = need( + row.connection_id, + "the row carries no connection_id", + ); + run.rowId = need(row.id, "the row carries no id"); + expect(run.connectionId).toMatch(/^ac_/); + expect(run.rowId).toMatch(/^at_/); + + const refusal = await failureOf(() => + client.admin.userConnections.delete(need(run.rowId, "no id")), + ); + expect(refusal.status).toBe(500); + + await client.admin.userConnections.delete(run.connectionId); + const left = []; + for await (const remaining of client.admin.userConnections.list({ + user_id: USER, + })) { + left.push(remaining); + } + expect(left).toEqual([]); + + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + expect( + await broker.isConnected({ account: ACCOUNT, toolkit: TOOLKIT }), + ).toBe(false); + }); +}); From ae74c0042a7a8f57a0726cef414da37115060437 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 15:48:18 +0200 Subject: [PATCH 103/232] fix(plugins): gate the grant list on a declared capability, not the vendor's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connected-account page drew its per-action grant list when `provider === "arcade"`. The two routes that serve the buttons in that list — `/servers/:id/authorize` and the `/arcade/verify` return leg — gate on `Boolean(broker.authorizations)` instead. A screen and the routes behind it keyed on different facts, which is the shape `routes.ts` already records as a defect: the day a broker named arcade is reached with `authorizations` null, the list renders and every Authorize press 503s. That the two agree today is luck, not a property. The plugins-page payload now carries `authorizesPerAction`, computed from the same presence test those routes gate on, and the screen reads it. `provider` stays a NAME and is untouched: it is what the vendor copy is written from, and a capability is a different question that two providers are free to share an answer to. False on Composio, so every Composio render is byte-identical. The sharpest test is the deployment where the two DISAGREE: a broker named arcade whose flow is absent serves `provider: "arcade"` with `authorizesPerAction: false`, and the screen draws no grant list. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/lib/plugins/queries.ts | 14 ++++++ .../settings/connected-accounts/$key.tsx | 13 +++--- .../admin-brokered-row-provider.test.tsx | 1 + app/tests/bot-app-grants-screen.test.tsx | 1 + app/tests/brokered-account-row.test.tsx | 1 + .../connected-account-confirmation.test.tsx | 9 ++++ app/tests/plugin-read-failure.test.tsx | 1 + app/tests/plugins-page-provider.test.ts | 1 + app/tests/skills-read-failure.test.tsx | 1 + server/src/plugins/routes.ts | 20 +++++++++ server/tests/plugins-page-provider.test.ts | 44 +++++++++++++++++++ 11 files changed, 101 insertions(+), 5 deletions(-) diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index e47e79c40..ad69cd0db 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -214,6 +214,20 @@ export type PluginsPage = { * server. */ composioConfigured: boolean; + /** + * Whether this deployment's broker authorizes ONE ACTION AT A TIME. + * + * A DECLARED CAPABILITY, read with a presence test and never a vendor name. The server computes + * it from the very expression `/servers/:id/authorize` and the `/arcade/verify` return leg gate + * on, so a screen that draws the per-action grant list and the routes that serve its buttons + * cannot key on different facts. Gating on `provider === "arcade"` instead left a broker named + * arcade whose flow is absent drawing a list whose every Authorize press 503s. + * + * Required, like `composioConfigured` and unlike `provider`: a capability is a value the server + * declares, so the type does not offer "did not say" as a third answer. False on Composio, whose + * auth config fixes one state for a whole toolkit, so a Composio deployment is unchanged. + */ + authorizesPerAction: boolean; /** * Which vendor brokers this deployment's apps, as a NAME. Read it through {@link pluginsProvider}. * diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 49adb9f3b..80e2b071b 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -183,12 +183,15 @@ function RouteComponent() { : null; const landedAction = landedActionFrom(action); /* - * THE GRANT LIST IS DRAWN FOR ARCADE ONLY. It is per-action and Arcade is the broker that grants - * per action; Composio's auth config fixes one state for the whole toolkit, its route answers the - * same degenerate entry N times, and the authorize route refuses it with 503. Gating on the NAME - * is what keeps every Composio render byte-identical. + * THE GRANT LIST IS DRAWN WHERE THE BROKER AUTHORIZES PER ACTION, read off the declared + * capability and never off the vendor's name. The server computes `authorizesPerAction` from the + * same expression `/servers/:id/authorize` behind the Authorize button gates on, so the list this + * screen draws and the route its buttons call cannot key on different facts. `provider === + * "arcade"` keyed on a different one: the day a broker named arcade is reached with that flow + * absent, this drew the list and every press 503'd. False on Composio — whose auth config fixes + * one state for the whole toolkit — so every Composio render is unchanged. */ - const perActionGrants = provider === "arcade"; + const perActionGrants = plugins.data?.authorizesPerAction ?? false; /* Who is asking only matters to a Shared app's Authorize, so it is not read for anything else. */ const sharedHere = server?.accountMode === "shared"; const me = useQuery({ diff --git a/app/tests/admin-brokered-row-provider.test.tsx b/app/tests/admin-brokered-row-provider.test.tsx index 95872e2a0..ab8bc2f30 100644 --- a/app/tests/admin-brokered-row-provider.test.tsx +++ b/app/tests/admin-brokered-row-provider.test.tsx @@ -97,6 +97,7 @@ function install(setup: { botsMayCallBack: true, redirectUri: null, composioConfigured: setup.configured, + authorizesPerAction: setup.provider === "arcade", provider: setup.provider, }; const json = (body: unknown, status = 200) => diff --git a/app/tests/bot-app-grants-screen.test.tsx b/app/tests/bot-app-grants-screen.test.tsx index 8fb4f3ac8..56f6b22b8 100644 --- a/app/tests/bot-app-grants-screen.test.tsx +++ b/app/tests/bot-app-grants-screen.test.tsx @@ -148,6 +148,7 @@ function pluginsPage(overrides: Partial = {}): PluginsPage { botsMayCallBack: true, redirectUri: null, composioConfigured: false, + authorizesPerAction: false, ...overrides, }; } diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx index 7ed799035..57a8442d7 100644 --- a/app/tests/brokered-account-row.test.tsx +++ b/app/tests/brokered-account-row.test.tsx @@ -195,6 +195,7 @@ function pluginsPage( botsMayCallBack: true, redirectUri: null, composioConfigured, + authorizesPerAction: false, }; } diff --git a/app/tests/connected-account-confirmation.test.tsx b/app/tests/connected-account-confirmation.test.tsx index c3dd91485..8d1da4706 100644 --- a/app/tests/connected-account-confirmation.test.tsx +++ b/app/tests/connected-account-confirmation.test.tsx @@ -104,6 +104,13 @@ type Setup = { siblings?: { siblings: string[]; unanswered: number }; /** Whether the siblings read answers 500. */ siblingsFail?: boolean; + /** + * Whether the deployment's broker authorizes per action, the capability the grant list is gated + * on. Defaults to what the named provider's broker declares today, so a case that says nothing + * about it gets the deployment it has always meant; set it against the name to build the + * deployment where the two DISAGREE. + */ + authorizesPerAction?: boolean; }; const siblingReads: string[] = []; @@ -117,6 +124,8 @@ function install(setup: Setup) { botsMayCallBack: true, redirectUri: null, composioConfigured: setup.configured ?? true, + authorizesPerAction: + setup.authorizesPerAction ?? setup.provider === "arcade", provider: setup.provider, }; const json = (body: unknown, status = 200) => diff --git a/app/tests/plugin-read-failure.test.tsx b/app/tests/plugin-read-failure.test.tsx index 4e88d3cae..9a9f062cd 100644 --- a/app/tests/plugin-read-failure.test.tsx +++ b/app/tests/plugin-read-failure.test.tsx @@ -84,6 +84,7 @@ function answeredWithout() { botsMayCallBack: true, redirectUri: null, composioConfigured: false, + authorizesPerAction: false, }; client.setQueryData(pluginKeys.page(), page); return client; diff --git a/app/tests/plugins-page-provider.test.ts b/app/tests/plugins-page-provider.test.ts index 72a4f1c01..ce5e344cb 100644 --- a/app/tests/plugins-page-provider.test.ts +++ b/app/tests/plugins-page-provider.test.ts @@ -8,6 +8,7 @@ const page = (extra: Partial): PluginsPage => ({ botsMayCallBack: false, redirectUri: null, composioConfigured: true, + authorizesPerAction: false, ...extra, }); diff --git a/app/tests/skills-read-failure.test.tsx b/app/tests/skills-read-failure.test.tsx index 4ea13347a..47d465eda 100644 --- a/app/tests/skills-read-failure.test.tsx +++ b/app/tests/skills-read-failure.test.tsx @@ -90,6 +90,7 @@ function answeredEmpty() { botsMayCallBack: true, redirectUri: null, composioConfigured: false, + authorizesPerAction: false, }; client.setQueryData(pluginKeys.page(), page); return client; diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 856c3012d..0f9e54d22 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -428,6 +428,26 @@ export function createPluginRoutes( * screen needs is whether to draw the app directory, which is a yes or a no. */ composioConfigured: Boolean(composio), + /* + * Whether this deployment's broker authorizes ONE ACTION AT A TIME, as a declared capability. + * + * A PRESENCE TEST ON A VALUE THE BROKER DECLARED, computed from the same expression the two + * routes that run that flow gate on — `/servers/:id/authorize` and the `/arcade/verify` + * return leg both read `composio.broker.authorizations`. The screen that draws the per-action + * grant list used to gate on `provider === "arcade"` instead, so the screen and the routes + * keyed on different facts: a broker named arcade reached with `authorizations` null would + * have drawn the list and 503'd every Authorize press. See {@link + * ConnectedAppBroker.authorizations} and `store.ts`'s gate comment, which records a defect of + * exactly that shape. + * + * A capability, never a name: what the screen asks is what the broker can DO, and two + * providers are free to share an answer. `provider` beside it stays a NAME and answers a + * different question — which vendor to put in copy. + * + * False on Composio, whose auth config fixes one state for a whole toolkit, so every Composio + * render is unchanged. + */ + authorizesPerAction: Boolean(composio?.broker.authorizations), /* * WHICH vendor brokers this deployment's apps, as a name. * diff --git a/server/tests/plugins-page-provider.test.ts b/server/tests/plugins-page-provider.test.ts index 9e9bcac95..2d2c7ac99 100644 --- a/server/tests/plugins-page-provider.test.ts +++ b/server/tests/plugins-page-provider.test.ts @@ -100,6 +100,7 @@ describe("the Plugins page names the active provider", () => { [ "botsMayCallBack", "catalogue", + "authorizesPerAction", "composioConfigured", "provider", "redirectUri", @@ -112,3 +113,46 @@ describe("the Plugins page names the active provider", () => { expect(body).not.toContain("grantIsLocal"); }); }); + +/* + * THE PER-ACTION CAPABILITY, which is a different question from the vendor's name and is answered + * by a presence test on what the broker declared. The two happen to agree on today's brokers; these + * cases pin that the payload follows the DECLARATION, because the routes behind the buttons the + * flag draws (`/servers/:id/authorize`, `/arcade/verify`) gate on that same expression. + */ +describe("the Plugins page declares whether the broker authorizes per action", () => { + test("a broker with an authorization flow serves true", async () => { + const page = await pageFor({ + broker: { key: VENDOR_KEY, authorizations: { verify: () => {} } }, + provider: "arcade", + }); + expect(page.authorizesPerAction).toBe(true); + }); + + test("a Composio deployment serves false", async () => { + const page = await pageFor({ + broker: { key: VENDOR_KEY, authorizations: null }, + provider: "composio", + }); + expect(page.authorizesPerAction).toBe(false); + }); + + test("a deployment with no broker serves false", async () => { + const page = await pageFor(undefined); + expect(page.authorizesPerAction).toBe(false); + }); + + /* + * WHERE THE NAME AND THE CAPABILITY DISAGREE, and the capability wins. This is the deployment the + * name comparison got wrong: `provider` still says arcade, because that is what the copy is + * written from, and the flag says false, because `/servers/:id/authorize` would refuse it 503. + */ + test("a broker named arcade with no flow serves arcade and false", async () => { + const page = await pageFor({ + broker: { key: VENDOR_KEY, authorizations: null }, + provider: "arcade", + }); + expect(page.provider).toBe("arcade"); + expect(page.authorizesPerAction).toBe(false); + }); +}); From c9f9f4ac652649f9c7ee50d48defd17ccd0da678 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 15:56:06 +0200 Subject: [PATCH 104/232] fix(arcade): land a deployment-held consent where a screen reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-action success 303 sent an account whose holder is "deployment" to /admin/plugins/?action=… — the screen its Authorize button lives on. That route has no validateSearch for `action`, no landing notice and no grant list, so the whole Shared-app flow, the one case whose Authorize is admin-only, completed a consent at the vendor and came back to a page that said nothing about it. The value was computed, put in a URL and read by nobody. Both holders now land on /settings/connected-accounts/:key?action=, the one screen that reads the parameter. The Shared branch there drew the grant list but not the notice, so the notice is added to it: landing had to actually name what was just authorized, not only mark a row some way down a search box. server/tests/arcade-verify.test.ts gains the deployment-holder landing the branch never had — a Shared app, an attempt held by the deployment identity, asserting the settings Location character for character, that it contains no /admin/, that it carries exactly one `?`, and that the grant written is the deployment's. app/tests/connected-account-grants.test.tsx asserts the rendered Shared landing: the status notice, the marked row, and silence without `?action=`. The journey suite asserted the old admin destination, so that one assertion is updated to the settings landing. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/connected-accounts/$key.tsx | 17 +++++++ app/tests/connected-account-grants.test.tsx | 44 ++++++++++++++++ server/src/plugins/routes.ts | 22 +++++--- .../tests/arcade-journey.integration.test.ts | 10 +++- server/tests/arcade-verify.test.ts | 50 +++++++++++++++++++ 5 files changed, 134 insertions(+), 9 deletions(-) diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 80e2b071b..775f63b63 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -309,6 +309,23 @@ function RouteComponent() { if (server?.accountMode === "shared") { return ( + {/* + * THE LANDING NOTICE BELONGS HERE TOO, because a Shared app's account is the deployment's + * and the verifier lands BOTH holders on this page. Without it the one flow whose Authorize + * is admin-only completed a consent at the vendor and came back to a screen that named + * nothing — the grant list's mark alone, several rows down a search box, is not the page + * saying what just happened. + */} + {landedAction ? ( +

+ {landedActionNotice(landedAction)} +

+ ) : null} + {/* One fact, so no heading and no actions: there is nothing here for this person to do. */} diff --git a/app/tests/connected-account-grants.test.tsx b/app/tests/connected-account-grants.test.tsx index 6c7386bf0..62125ccc8 100644 --- a/app/tests/connected-account-grants.test.tsx +++ b/app/tests/connected-account-grants.test.tsx @@ -493,6 +493,50 @@ test("a Shared app shows state to a member but Authorize only to an administrato await waitFor(() => expect(admin.queryByText("Authorize")).not.toBeNull()); }); +/* + * THE SHARED APP'S LANDING, which is where a deployment-held consent now comes back to. + * + * The verifier used to send this holder to `/admin/plugins/:key`, a route with no `validateSearch` + * for `action`, no notice and no grant list — so the one flow whose Authorize is admin-only + * finished a consent at the vendor and landed on a screen that named nothing. Asserted on RENDERED + * markup for that reason: the destination mattering is exactly the claim. + */ +test("a Shared app landing from a consent names the action and marks its row", async () => { + install({ + provider: "arcade", + connected: false, + accountMode: "shared", + role: "admin", + grants: GMAIL, + }); + const view = renderScreen("?action=Gmail.ListEmails"); + + const notice = await view.findByRole("status"); + expect(notice.textContent).toBe("You just authorized Gmail.ListEmails."); + expect(notice.getAttribute("data-landed-action")).toBe("Gmail.ListEmails"); + + // And the row itself is the marked one, with no search typed to find it by. + await waitFor(() => row(view, "Gmail.ListEmails")); + expect(row(view, "Gmail.ListEmails").hasAttribute("data-landed")).toBe(true); + expect(row(view, "Gmail.SendEmail").hasAttribute("data-landed")).toBe(false); + // The Shared branch is the one drawing this, not the personal-account branch. + expect(view.container.textContent).toContain("Team account"); +}); + +test("a Shared app reached without an action says nothing about authorizing", async () => { + install({ + provider: "arcade", + connected: false, + accountMode: "shared", + role: "admin", + grants: GMAIL, + }); + const view = renderScreen(); + await waitFor(() => row(view, "Gmail.ListEmails")); + expect(view.queryByRole("status")).toBeNull(); + expect(view.container.textContent).not.toContain("just authorized"); +}); + // ---- Part B: the consent copy is RENDERED before the redirect ---- test("pressing Authorize renders consentCopy's sentence and redirects nowhere yet", async () => { diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 0f9e54d22..6dde95dbf 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2990,15 +2990,23 @@ export function createPluginRoutes( * every `next_uri` at mint, and what `confirm_user` answers is the vendor's OWN success page * rather than ours. The row holds the app and the action this consent was for, and it cannot * be missing here — this handler refused long before it needed an address — so there is no - * fallback to specify. An account the deployment holds goes back to the admin screen it was - * started from, as the mint's own return address does. + * fallback to specify. + * + * AND BOTH HOLDERS LAND ON THE SAME SCREEN, which is the only one that reads `?action=`. An + * account the deployment holds used to be sent to `/admin/plugins/:key`, because that is + * where its Authorize button lives — but that route has no `validateSearch` for `action`, no + * landing notice and no grant list, so the whole Shared-app flow completed a consent at the + * vendor and landed somewhere that said nothing about it. The settings page draws the Shared + * app's grant list and the landing notice for either holder, so this is where the action + * somebody just authorized is actually named and marked. + * + * `connectedAccountsUrlFor` with `{ serverId }` answers a query-less path, so appending + * `?action=` here cannot produce a second `?`. */ return context.redirect( - `${connectedAccountsUrlFor( - connect?.appUrl, - { serverId: attempt.serverId }, - fresh.account.holder === "deployment" ? "admin" : "settings", - )}?action=${encodeURIComponent(attempt.action)}`, + `${connectedAccountsUrlFor(connect?.appUrl, { + serverId: attempt.serverId, + })}?action=${encodeURIComponent(attempt.action)}`, 303, ); } catch { diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index 6ab1e63c8..adebc6da7 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -945,9 +945,15 @@ describe("two separately-shared apps", () => { const response = await minting.returning(); expect(response.status).toBe(303); - // A deployment's account goes back to the admin screen it was started from. + /* + * AND IT LANDS WHERE THE ACTION IS READ, which for a deployment-held account is the same + * settings page a personal one lands on. It used to go to `/admin/plugins/:key`, the screen + * the Authorize was pressed from — but that route reads no `?action=` and draws no grant + * list, so the Shared-app flow ended on a page that said nothing about the consent just + * given. + */ expect(response.headers.get("location")).toBe( - `${APP_URL}/admin/plugins/${rowId}?action=${encodeURIComponent(actionName)}`, + `${APP_URL}/settings/connected-accounts/${rowId}?action=${encodeURIComponent(actionName)}`, ); const connections = await connectionsOf(app); diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 6966ab19b..91826378e 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -779,6 +779,56 @@ describe("a return leg that completes", () => { expect(row?.completedAt).not.toBeNull(); }); + /** + * A DEPLOYMENT-HELD ACCOUNT LANDS ON THE SAME SCREEN, which is the branch that had no test and + * so shipped pointing at `/admin/plugins/:key` — a route with no `validateSearch` for `action`, + * no landing notice and no grant list. The whole Shared-app flow, the one case whose Authorize + * is admin-only, finished a consent at the vendor and came back to a page that said nothing + * about it. The destination is asserted CHARACTER FOR CHARACTER against the settings landing, + * including the single `?`, because that is the only address any screen reads `?action=` off. + */ + test("a consent the DEPLOYMENT holds lands on the same settings page, not on admin", async () => { + await database + .update(mcpServers) + .set({ accountMode: "shared", sharedVendorUserId: SHARED_IDENTITY }) + .where(eq(mcpServers.id, APP)); + await database.insert(pendingAuthorizations).values({ + actorId: ACTOR, + serverId: APP, + action: ACTION, + holder: "deployment", + userId: null, + vendorUserId: SHARED_IDENTITY, + authId: MINTED, + createdAt: new Date(Math.floor(Date.now() / 1000) * 1000), + }); + statusAnswer = async () => ({ + status: "completed", + userId: SHARED_IDENTITY, + scopes: ["gmail.read"], + }); + + const response = await verify(ACTOR, "?flow_id=flow-abc", { + roles: ["admin"], + }); + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe(LANDING); + expect(response.headers.get("location")).not.toContain("/admin/"); + // One query parameter, so the appended `?action=` cannot have met a path that already had one. + expect(response.headers.get("location")?.split("?").length).toBe(2); + + // AND IT IS THE DEPLOYMENT'S GRANT THAT WAS WRITTEN, so this is the holder whose landing is + // being asserted and not a personal one dressed up as it. + const { connections } = await written(); + expect(connections.length).toBe(1); + expect(connections[0]?.holder).toBe("deployment"); + expect(connections[0]?.userId).toBeNull(); + expect(connections[0]?.vendorUserId).toBe(SHARED_IDENTITY); + + const row = await attemptRow(); + expect(row?.outcome).toBe("completed"); + }); + /** * A VENDOR THAT DOES NOT SAY WHICH AUTH PROVIDER IT IS records that it did not say, and the * consent still completes: the grant is already live at the vendor, and an audit label is not From 6676ee0d2a41e1e55d1faebc1e841ece68f5e630 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 16:06:14 +0200 Subject: [PATCH 105/232] refactor(plugins): one grant-state ordering table, shared by the sort and the unauthorized draw RANK in grants.ts and ORDER in grant-list.tsx were byte-identical. Reordering one compiles clean and diverges silently: ORDER decides WHICH ten unauthorized actions are drawn with no search term, RANK decides their render order. Lead ORDER with unknown or needs-operator and a fresh account with more than ten unauthorized actions draws ten Retry / "administrator must set X" rows, no Authorize button, every consentable action hidden behind "N more actions not yet authorized." RANK is now exported and GrantList sorts with it. Adds a mixed-state test (13 unauthorized actions) asserting which ten are drawn; it fails if RANK is reordered. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/plugins/grant-list.tsx | 13 +----- app/src/lib/plugins/grants.ts | 3 +- app/tests/connected-account-grants.test.tsx | 52 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/app/src/components/plugins/grant-list.tsx b/app/src/components/plugins/grant-list.tsx index c9a7b650e..e98fc238e 100644 --- a/app/src/components/plugins/grant-list.tsx +++ b/app/src/components/plugins/grant-list.tsx @@ -19,6 +19,7 @@ import { type GrantState, grantsQueryOptions, groupGrants, + RANK, } from "@/lib/plugins/grants"; /** @@ -39,16 +40,6 @@ import { /** How many unauthorized actions are drawn before the rest are left to the search. */ const UNAUTHORIZED_SHOWN = 10; -/** - * The order the unauthorized tail is picked in, matching `groupGrants`'s own ranking so what is - * drawn with no search term is the front of the same list the groups are sorted by. - */ -const ORDER: Record = { - authorized: 0, - "needs-consent": 1, - "needs-operator": 2, - unknown: 3, -}; export function GrantList({ serverId, appNames, @@ -132,7 +123,7 @@ export function GrantList({ .filter((grant) => grant.state !== "authorized") .sort( (left, right) => - ORDER[left.state] - ORDER[right.state] || + RANK[left.state] - RANK[right.state] || left.action.localeCompare(right.action), ); /* With no search term the first unauthorized actions are drawn as well: an Authorize that only diff --git a/app/src/lib/plugins/grants.ts b/app/src/lib/plugins/grants.ts index 9637f134a..63456ab91 100644 --- a/app/src/lib/plugins/grants.ts +++ b/app/src/lib/plugins/grants.ts @@ -109,7 +109,8 @@ function scopesOf(grant: GrantState): string[] { : []; } -const RANK: Record = { +/** The one state ordering: `groupGrants` sorts by it and `GrantList` picks its unauthorized tail by it. */ +export const RANK: Record = { authorized: 0, "needs-consent": 1, "needs-operator": 2, diff --git a/app/tests/connected-account-grants.test.tsx b/app/tests/connected-account-grants.test.tsx index 62125ccc8..14a31d0b1 100644 --- a/app/tests/connected-account-grants.test.tsx +++ b/app/tests/connected-account-grants.test.tsx @@ -465,6 +465,58 @@ test("past the first ten unauthorized actions the rest wait behind the search", ).toBeNull(); }); +test("with mixed unauthorized states past ten, the ten drawn are in state order: every consentable action first", async () => { + // Names sort the OTHER way round to the state ranking (unknown < operator < consent by name), so + // any reordering of the single RANK table changes which ten are drawn. + const mixed: GrantState[] = [ + ...Array.from( + { length: 4 }, + (_, index): GrantState => ({ + action: `Gmail.A${index}`, + state: "unknown", + reason: "Try again.", + }), + ), + ...Array.from( + { length: 3 }, + (_, index): GrantState => ({ + action: `Gmail.B${index}`, + state: "needs-operator", + missing: ["GOOGLE_CLIENT_SECRET"], + }), + ), + ...Array.from( + { length: 6 }, + (_, index): GrantState => ({ + action: `Gmail.C${index}`, + state: "needs-consent", + scopes: ["gmail.send"], + }), + ), + ]; + install({ provider: "arcade", connected: true, grants: mixed }); + const view = renderScreen(); + await view.findByText(/more actions not yet authorized/); + const drawn = [...view.container.querySelectorAll("[data-action]")] + .map((element) => element.getAttribute("data-action")) + .sort(); + expect(drawn).toEqual([ + "Gmail.A0", + "Gmail.B0", + "Gmail.B1", + "Gmail.B2", + "Gmail.C0", + "Gmail.C1", + "Gmail.C2", + "Gmail.C3", + "Gmail.C4", + "Gmail.C5", + ]); + expect(view.container.textContent).toContain( + "3 more actions not yet authorized", + ); +}); + // ---- 4: Shared ---- test("a Shared app shows state to a member but Authorize only to an administrator", async () => { From 4b7185f15c87b01176d3f7cc6c7ce64828a366f8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 16:06:40 +0200 Subject: [PATCH 106/232] fix(arcade): stop the authorize route encoding the refuted deployment-to-admin landing The per-action authorize route still computed returnTo as "admin" for a deployment-held account, the exact expression c9f9f4ac removed from the verify leg. Drop that arm so both legs agree; an explicit ?returnTo=admin is unchanged. Also delete a false clause from the verify leg's comment: it said the deployment holder went to /admin/plugins/:key because its Authorize button lives there. It does not; a Shared app's Authorize is on the settings page's Shared branch. Both changes are inert today: the Arcade adapter records the returnUrl as accepted and ignored, and no GrantList renders with returnTo="admin". They are worth making anyway, because this was the one place still encoding the refuted decision, and the day a broker honours the return address a deployment-held consent would land on a page that reads no ?action=. A comment stating a false reason is evidence that misleads the next reader. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 6dde95dbf..3ef2face3 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2555,11 +2555,7 @@ export function createPluginRoutes( ); } const returnTo: ConnectOrigin = - account.holder === "deployment" - ? "admin" - : context.req.query("returnTo") === "admin" - ? "admin" - : "settings"; + context.req.query("returnTo") === "admin" ? "admin" : "settings"; let returnUrl: string; try { returnUrl = brokerReturnUrl( @@ -2993,10 +2989,9 @@ export function createPluginRoutes( * fallback to specify. * * AND BOTH HOLDERS LAND ON THE SAME SCREEN, which is the only one that reads `?action=`. An - * account the deployment holds used to be sent to `/admin/plugins/:key`, because that is - * where its Authorize button lives — but that route has no `validateSearch` for `action`, no - * landing notice and no grant list, so the whole Shared-app flow completed a consent at the - * vendor and landed somewhere that said nothing about it. The settings page draws the Shared + * account the deployment holds used to be sent to `/admin/plugins/:key`, but that route has no + * `validateSearch` for `action`, no landing notice and no grant list, so the whole Shared-app + * flow completed a consent at the vendor and landed somewhere that said nothing about it. The settings page draws the Shared * app's grant list and the landing notice for either holder, so this is where the action * somebody just authorized is actually named and marked. * From f4c5e87babff655ce7512913648d7af864ba6622 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 16:22:32 +0200 Subject: [PATCH 107/232] fix(arcade): open the verifier's blanket try before its first store read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route states a criterion in its own docblock: no request to this endpoint ends without a `Location`, with one stated exception, the 403 for a roleless session. It did not hold. The blanket `try` opened at the completion, which left two awaited calls reaching a store outside any `catch`: - `requireUser`, whose session read and `rolesForUser` read are live reads that can throw rather than return a `Response`; - `store.authorizationAttemptFor`, the one store read on the happy path before the completion. A throw from either left the handler by exception, and there is no `app.onError` on the main app, so the request ended as Hono's default bodyless 500 with no `Location` — on an API origin that serves no pages, to somebody who had just completed a consent at a third party. A database blip or an exhausted pool during the attempt read is the ordinary way to reach it. The `try` now opens immediately after `flowId` is read, so both calls are inside it, keeping the single existing `catch` and its `not-recorded`. The inner `try`s still take the vendor's throws first, so no refusal code changes and the closed set of ten exits is untouched. The 403 RETURNS a response and a `catch` sees throws and not returns, so the stated exception keeps its JSON body. Two cases added: a throwing `authorizationAttemptFor` and a throwing guard role read, each asserting a `Location` and that nothing was written. The gap survived because the only pinned throw was INSIDE the completion. Also rewraps a comment line the previous commit left at 131 characters. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 269 +++++++++++++++-------------- server/tests/arcade-verify.test.ts | 67 ++++++- 2 files changed, 207 insertions(+), 129 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 3ef2face3..f38017132 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2747,141 +2747,156 @@ export function createPluginRoutes( */ const flowId = (context.req.query("flow_id") ?? "").trim(); - let signedIn = false; - const refusal = await requireUser(context, async () => { - signedIn = true; - }); - if (!signedIn) { - /* - * ONLY A MISSING SESSION GOES TO SIGN-IN. The guard also refuses 403 for a session with no - * role, and sending that person to sign-in is a dead end: the sign-in screen's own load hits - * the same guard, gets the same 403, and they sign in as themselves again with nothing - * changed and never told their access was removed. - */ - if (refusal instanceof Response && refusal.status === 401) { - return context.redirect(signInUrlFor(connect?.appUrl, flowId), 303); - } - return context.json( - { - error: - "This deployment no longer recognises you, so nothing was recorded. Ask an administrator to restore your access, then start the authorization again from the app's page.", - }, - 403, - ); - } - - /* - * THE CAPABILITY GATE, AND IT IS A PRESENCE TEST ON A VALUE THE BROKER DECLARED. Never a - * `provider === "arcade"` comparison: see {@link ConnectedAppBroker.authorizations}, and - * `store.ts`'s gate comment, which records a defect where a gate and its exemption keyed on - * different things and the exemption failed OPEN. A deployment whose broker runs no correlated - * authorization flow has no return leg for this route to verify. - */ - if (!composio) return refusedWith("no-broker"); - const flow = composio.broker.authorizations; - if (!flow) return refusedWith("not-supported"); - /* - * A HANDLE THIS DEPLOYMENT CANNOT USE IS REFUSED WITHOUT ASKING THE VENDOR ANYTHING. Empty, - * missing or whitespace-only are one fact — the browser arrived without the one thing the - * return leg carries — and spending a vendor call on it would be an unauthenticated-shaped - * request reaching another company's API. - */ - if (!flowId) return refusedWith("missing-handle"); - - /* - * THE ATTEMPT COMES FROM THE SIGNED-IN ACTOR AND NEVER FROM THE HANDLE. The vendor's return-leg - * id and the id its mint answered with are different id spaces with no endpoint between them, - * and nothing here may try to bridge them; trying identities until one is accepted would be an - * oracle reporting which of this deployment's identities have flows outstanding. The session - * names exactly one candidate, which is what makes the correlation exact rather than probable. + * EVERYTHING FROM HERE IS IN ONE `try`, FOR THE REASON `/oauth/callback` BELOW GIVES FOR ITS + * OWN: so it is the whole body rather than each call. A blanket `catch` is a promise about the + * route; a per-call one is a promise about the calls somebody remembered. The criterion is + * that no request to this endpoint ends without a `Location`, and a call that throws would + * otherwise put somebody who has just consented at another company on a bodyless 500 — there + * is no `app.onError` on this app to turn that into anything else, and this is an origin that + * serves no pages. * - * {@link PluginStore.authorizationAttemptFor} already excludes a completed attempt and one past - * the fifteen-minute TTL, so all three absences arrive here as `null` and are refused in one - * sentence. A NULL `auth_id` is the fourth: its mint never completed, so there is nothing for - * the binding below to be checked against and the attempt can only ever expire. - */ - const actorId = context.var.actor.id; - const attempt = await store.authorizationAttemptFor(actorId); - if (!attempt || attempt.authId === null) return refusedWith("no-attempt"); - const authId = attempt.authId; - - /* - * THE BINDING. `handle` IS AN OPAQUE STRING THE SEAM DOES NOT INTERPRET — the vendor's word for - * it stops at the adapter, and nothing here parses it, compares it to a scheme or reads a - * provider out of it. + * IT OPENS HERE AND NOT AT THE COMPLETION, which is where it used to open and where it was a + * promise about the calls somebody remembered. Two awaited reads happen first and both reach a + * store: `requireUser` reads the session and then this actor's roles, and + * `authorizationAttemptFor` is the one query on the happy path. A database blip or an + * exhausted pool during the attempt read is the ordinary way to reach either. * - * THE MISMATCH BRANCH IS ASSUMPTION-BACKED AND IS MOCKED-ONLY UNTIL A LIVE PROBE RUNS. The SDK - * declares no error shape for a handle belonging to somebody else; the adapter detects one by - * normalizing the thrown error and reading `user_mismatch`, and RETHROWS anything else. If the - * vendor's real shape differs, `confirmReturn` does not quietly answer `{ mismatch: true }` — - * it throws, and the branch below surfaces an outage instead of a clean refusal. That is UX - * degradation and not a hole: nothing is written and the attempt row is left alone either way, - * and the `auth_id` comparison underneath independently covers a `confirm` that SUCCEEDS - * against the wrong identity. Do not read a passing mocked test as proof this path works. + * THE STATED EXCEPTION IS UNTOUCHED BY THIS. The 403 for a session with no role RETURNS, and a + * `catch` sees throws and not returns, so it keeps its JSON body. + * + * The inner `try`s below take the vendor's throws first, so no refusal code changes here. The + * completion transaction is all-or-nothing, so a throw out of it has written neither the + * connection row nor its audit entry. */ - let confirmed: Awaited>; try { - confirmed = await flow.confirmReturn({ - handle: flowId, - vendorUserId: attempt.vendorUserId, + let signedIn = false; + const refusal = await requireUser(context, async () => { + signedIn = true; }); - } catch { + if (!signedIn) { + /* + * ONLY A MISSING SESSION GOES TO SIGN-IN. The guard also refuses 403 for a session with no + * role, and sending that person to sign-in is a dead end: the sign-in screen's own load hits + * the same guard, gets the same 403, and they sign in as themselves again with nothing + * changed and never told their access was removed. + */ + if (refusal instanceof Response && refusal.status === 401) { + return context.redirect(signInUrlFor(connect?.appUrl, flowId), 303); + } + return context.json( + { + error: + "This deployment no longer recognises you, so nothing was recorded. Ask an administrator to restore your access, then start the authorization again from the app's page.", + }, + 403, + ); + } + /* - * NOTHING FROM THE VENDOR'S ANSWER TRAVELS WITH THIS — not its sentence, not its body, not - * the error. A reply on this leg can carry an authorization url, which is a bearer capability, - * and an outage is not worth a person's consent being quotable out of an error body. + * THE CAPABILITY GATE, AND IT IS A PRESENCE TEST ON A VALUE THE BROKER DECLARED. Never a + * `provider === "arcade"` comparison: see {@link ConnectedAppBroker.authorizations}, and + * `store.ts`'s gate comment, which records a defect where a gate and its exemption keyed on + * different things and the exemption failed OPEN. A deployment whose broker runs no correlated + * authorization flow has no return leg for this route to verify. */ - return refusedWith("vendor-unavailable"); - } + if (!composio) return refusedWith("no-broker"); + const flow = composio.broker.authorizations; + if (!flow) return refusedWith("not-supported"); - /* - * TWO REFUSALS, ONE SENTENCE, AND THE ATTEMPT ROW IS LEFT UNTOUCHED BY BOTH. A handle the vendor - * says belongs to somebody else, and a confirmation naming a DIFFERENT authorization than the - * one this deployment minted, are both "this return leg is not for the attempt you are holding". - * - * THE `auth_id` COMPARISON IS NOT OPTIONAL AND IT IS NOT HOPEFUL: the confirmation's id was - * measured IDENTICAL to the mint's through a completed consent, so this is an exact equality - * over one id space. Identity alone is not correlation — an expired read attempt, a fresh send - * attempt under the same identity, and then the read's callback arriving late matches on - * identity at every step, and a check stopping there would attribute a read consent to a send - * request. Local expiry does not cancel a vendor flow, so late callbacks are ordinary. - * - * NOTHING IS WRITTEN AND NOTHING IS CLOSED. A late callback must never consume or ALTER the - * replacement it arrived behind, so the row is not deleted, not completed and not marked. - */ - if ("mismatch" in confirmed || confirmed.authorizationId !== authId) { - return refusedWith("not-your-authorization"); - } + /* + * A HANDLE THIS DEPLOYMENT CANNOT USE IS REFUSED WITHOUT ASKING THE VENDOR ANYTHING. Empty, + * missing or whitespace-only are one fact — the browser arrived without the one thing the + * return leg carries — and spending a vendor call on it would be an unauthenticated-shaped + * request reaching another company's API. + */ + if (!flowId) return refusedWith("missing-handle"); - /* - * THE BOUNDED WAIT. `pending` is polled a few times over a few seconds and never with the SDK's - * unbounded `waitForCompletion`: a browser is held open on this request, and a vendor that has - * stopped answering must not hold it open forever. See {@link settledStatus}. - * - * NOTHING FROM THAT ANSWER TRAVELS ANYWHERE (D31). The vendor's own status reply carries a LIVE - * PROVIDER ACCESS TOKEN and the person's full profile; the seam narrows it to three fields - * inside the adapter, and the three are read here into a decision, a comparison and the scope - * list the completion writes. Not one of them is logged, attached as a `cause` or put in an - * error body, and the status object itself reaches none of those either. A status response - * LOOKS LIKE METADATA, which is how the throwaway harness that proved this flow came to write - * the token to a file on its first run. - */ - let settled: Awaited>; - try { - settled = await settledStatus(flow, authId); - } catch { - return refusedWith("vendor-unavailable"); - } + /* + * THE ATTEMPT COMES FROM THE SIGNED-IN ACTOR AND NEVER FROM THE HANDLE. The vendor's return-leg + * id and the id its mint answered with are different id spaces with no endpoint between them, + * and nothing here may try to bridge them; trying identities until one is accepted would be an + * oracle reporting which of this deployment's identities have flows outstanding. The session + * names exactly one candidate, which is what makes the correlation exact rather than probable. + * + * {@link PluginStore.authorizationAttemptFor} already excludes a completed attempt and one past + * the fifteen-minute TTL, so all three absences arrive here as `null` and are refused in one + * sentence. A NULL `auth_id` is the fourth: its mint never completed, so there is nothing for + * the binding below to be checked against and the attempt can only ever expire. + */ + const actorId = context.var.actor.id; + const attempt = await store.authorizationAttemptFor(actorId); + if (!attempt || attempt.authId === null) return refusedWith("no-attempt"); + const authId = attempt.authId; + + /* + * THE BINDING. `handle` IS AN OPAQUE STRING THE SEAM DOES NOT INTERPRET — the vendor's word for + * it stops at the adapter, and nothing here parses it, compares it to a scheme or reads a + * provider out of it. + * + * THE MISMATCH BRANCH IS ASSUMPTION-BACKED AND IS MOCKED-ONLY UNTIL A LIVE PROBE RUNS. The SDK + * declares no error shape for a handle belonging to somebody else; the adapter detects one by + * normalizing the thrown error and reading `user_mismatch`, and RETHROWS anything else. If the + * vendor's real shape differs, `confirmReturn` does not quietly answer `{ mismatch: true }` — + * it throws, and the branch below surfaces an outage instead of a clean refusal. That is UX + * degradation and not a hole: nothing is written and the attempt row is left alone either way, + * and the `auth_id` comparison underneath independently covers a `confirm` that SUCCEEDS + * against the wrong identity. Do not read a passing mocked test as proof this path works. + */ + let confirmed: Awaited>; + try { + confirmed = await flow.confirmReturn({ + handle: flowId, + vendorUserId: attempt.vendorUserId, + }); + } catch { + /* + * NOTHING FROM THE VENDOR'S ANSWER TRAVELS WITH THIS — not its sentence, not its body, not + * the error. A reply on this leg can carry an authorization url, which is a bearer capability, + * and an outage is not worth a person's consent being quotable out of an error body. + */ + return refusedWith("vendor-unavailable"); + } + + /* + * TWO REFUSALS, ONE SENTENCE, AND THE ATTEMPT ROW IS LEFT UNTOUCHED BY BOTH. A handle the vendor + * says belongs to somebody else, and a confirmation naming a DIFFERENT authorization than the + * one this deployment minted, are both "this return leg is not for the attempt you are holding". + * + * THE `auth_id` COMPARISON IS NOT OPTIONAL AND IT IS NOT HOPEFUL: the confirmation's id was + * measured IDENTICAL to the mint's through a completed consent, so this is an exact equality + * over one id space. Identity alone is not correlation — an expired read attempt, a fresh send + * attempt under the same identity, and then the read's callback arriving late matches on + * identity at every step, and a check stopping there would attribute a read consent to a send + * request. Local expiry does not cancel a vendor flow, so late callbacks are ordinary. + * + * NOTHING IS WRITTEN AND NOTHING IS CLOSED. A late callback must never consume or ALTER the + * replacement it arrived behind, so the row is not deleted, not completed and not marked. + */ + if ("mismatch" in confirmed || confirmed.authorizationId !== authId) { + return refusedWith("not-your-authorization"); + } + + /* + * THE BOUNDED WAIT. `pending` is polled a few times over a few seconds and never with the SDK's + * unbounded `waitForCompletion`: a browser is held open on this request, and a vendor that has + * stopped answering must not hold it open forever. See {@link settledStatus}. + * + * NOTHING FROM THAT ANSWER TRAVELS ANYWHERE (D31). The vendor's own status reply carries a LIVE + * PROVIDER ACCESS TOKEN and the person's full profile; the seam narrows it to three fields + * inside the adapter, and the three are read here into a decision, a comparison and the scope + * list the completion writes. Not one of them is logged, attached as a `cause` or put in an + * error body, and the status object itself reaches none of those either. A status response + * LOOKS LIKE METADATA, which is how the throwaway harness that proved this flow came to write + * the token to a file on its first run. + */ + let settled: Awaited>; + try { + settled = await settledStatus(flow, authId); + } catch { + return refusedWith("vendor-unavailable"); + } - /* - * EVERYTHING FROM HERE IS IN ONE `try`, FOR THE REASON `/oauth/callback` BELOW GIVES FOR ITS - * OWN: the criterion is that no request to this endpoint ends without a `Location`, and a store - * call that throws would otherwise put somebody who has just consented at another company on a - * bodyless 500 on an origin that serves no pages. The completion transaction is all-or-nothing, - * so a throw out of it has written neither the connection row nor its audit entry. - */ - try { /* * WHAT EACH OUTCOME DOES TO THE ATTEMPT ROW, AND THE TIMEOUT IS THE ONE THAT LEAVES IT ALONE. * `failed` and an identity that is not the attempt's are verdicts, so the row is consumed and @@ -2991,9 +3006,9 @@ export function createPluginRoutes( * AND BOTH HOLDERS LAND ON THE SAME SCREEN, which is the only one that reads `?action=`. An * account the deployment holds used to be sent to `/admin/plugins/:key`, but that route has no * `validateSearch` for `action`, no landing notice and no grant list, so the whole Shared-app - * flow completed a consent at the vendor and landed somewhere that said nothing about it. The settings page draws the Shared - * app's grant list and the landing notice for either holder, so this is where the action - * somebody just authorized is actually named and marked. + * flow completed a consent at the vendor and landed somewhere that said nothing about it. The + * settings page draws the Shared app's grant list and the landing notice for either holder, + * so this is where the action somebody just authorized is actually named and marked. * * `connectedAccountsUrlFor` with `{ serverId }` answers a query-less path, so appending * `?action=` here cannot produce a second `?`. diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 91826378e..c9230746d 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -148,6 +148,14 @@ function build( brokerBag?: { broker: ConnectedAppBroker; provider: string } | undefined; /** A handle whose behaviour one case alters; everything else runs on the suite's own. */ database?: typeof database; + /** + * Makes the ONE store read that happens before the completion throw. Not a broken `database` + * handle, because that read is a plain `select` the rest of the deployment's boot shares — the + * case is about this route's own call, so this replaces exactly that method. + */ + attemptReadThrows?: boolean; + /** Makes the guard's ROLE read throw, which is a store read outside the handler's own code. */ + roleReadThrows?: boolean; } = {}, ) { const handle = options.database ?? database; @@ -175,9 +183,21 @@ function build( : { user: { id: who, email: `${who}@example.test`, name: "Person" } }, }, } as never; - args[2] = { rolesForUser: async () => options.roles ?? ["user"] } as never; + args[2] = { + rolesForUser: async () => { + if (options.roleReadThrows) throw new Error("injected role read failure"); + return options.roles ?? ["user"]; + }, + } as never; args[12] = auditStore; - args[14] = pluginStore; + args[14] = options.attemptReadThrows + ? { + ...pluginStore, + authorizationAttemptFor: async () => { + throw new Error("injected attempt read failure"); + }, + } + : pluginStore; args[29] = "brokerBag" in options ? options.brokerBag : { broker, provider: "arcade" }; return createApp(...args); @@ -504,6 +524,49 @@ describe("a return leg with a session whose role was removed", () => { }); }); +/** + * THE CRITERION HELD WHERE IT WAS NOT HELD: a throw BEFORE the completion. + * + * The case further down pins a throw INSIDE the completion transaction, and that is the only place + * anything pinned one — which is how the two awaited reads that happen EARLIER came to sit outside + * the blanket `try`. A throw from either ended the request as Hono's default bodyless 500, with no + * `Location` at all, for somebody who had just finished a consent at another company. There is no + * `app.onError` on this app to turn that into anything else. + * + * `not-recorded` IS THE CODE FOR BOTH, because it is what the criterion requires and because it is + * true: nothing was recorded. Neither case adds an exit, so the closed set above is unchanged. + */ +describe("a store that throws before anything could be written", () => { + test("the attempt read throwing still answers a Location", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc", { attemptReadThrows: true }), + "not-recorded", + ); + // The vendor was never asked, and the row is left exactly as it was. + expect(confirmCalls).toEqual([]); + expect(statusCalls).toEqual([]); + const row = await attemptRow(); + expect(row?.completedAt).toBeNull(); + expect(row?.outcome).toBeNull(); + await expectNothingWritten(); + }); + + /** + * THE GUARD'S OWN READS ARE INSIDE THE `try` TOO, and this does not disturb the 403 above: that + * branch RETURNS a response, and a `catch` sees throws and not returns. + */ + test("the guard's role read throwing still answers a Location", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc", { roleReadThrows: true }), + "not-recorded", + ); + expect(confirmCalls).toEqual([]); + await expectNothingWritten(); + }); +}); + describe("a deployment whose broker runs no correlated authorization flow", () => { test("a broker declaring authorizations: null refuses as not supported", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); From 96528ad0b90f59591af8cbce4968387534b1b311 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 16:30:54 +0200 Subject: [PATCH 108/232] docs(arcade): the refusal set's docblock says eleventh, which is what the list is The sentence read "a seventh cannot be added" above a list of ten, and was already wrong when it landed. Amendment 21 corrected this vocabulary in terms: six was never the invariant, an eleventh is allowed, and adding one WITHOUT reddening both set assertions is not. The comment contradicted the array beside it, which is the kind of sentence a reviewer trusts instead of counting. Both assertions were and remain correct. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index f38017132..95048893f 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -156,7 +156,7 @@ function signInUrlFor(appUrl: string | undefined, flowId: string): string { * rather than in two that drift. The parameter is `?authorized=`, a channel of its own: `?connected=` * belongs to the Composio OAuth callback and is neither reused nor widened here. * - * ASSERTED AS A SET so a seventh cannot be added without a test going red, and so the app's own map + * ASSERTED AS A SET so an eleventh cannot be added without a test going red, and so the app's own map * can be held to exactly the same list. */ export const AUTHORIZATION_REFUSALS = [ From 9fa2d6edc1cc3e8b4e30d9171c1bf2a0d80f9c1e Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 16:54:07 +0200 Subject: [PATCH 109/232] docs(arcade): say why two brokered_connections reads carry no provider predicate retireConnectionsFor and brokeredConnectionsFor select by (holder, userId) with no brokerProvider filter. Pre-existing: both were unfiltered at the branch base 7b1c20a, and neither is reachable today. D3's startup activation refusal (assertActivatable, server/src/index.ts) is the only thing holding them closed: a deployment holding another provider's rows cannot boot. If it did, the offboarding sweep would ask the active broker to revoke a foreign connection and audit it as requested. The predicate is deferred to the provider sunset flow, which relaxes that refusal and makes both sites live. Comments only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 1e1797de2..d075e1415 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -5673,6 +5673,14 @@ export function createPluginStore(options: PluginStoreOptions) { * draw the deployment's shared account as one of them — a deployment row has no `user_id`, * and the filter keeps that a fact this query states rather than one it happens to rely on. */ + /* + * NO PROVIDER PREDICATE HERE, DELIBERATELY, AND THAT IS NOT AN OVERSIGHT. Each row is resolved + * below at the ACTIVE provider's canonical url, so another provider's row would be silently + * dropped from the page. What keeps that unreachable is the startup activation refusal + * (`assertActivatable`, called from `server/src/index.ts`): it will not boot when the + * configured provider differs from the one on the existing rows. The provider sunset flow + * relaxes that refusal, and whoever builds it must add the `brokerProvider` predicate here. + */ const connections = await database .select({ toolkit: brokeredConnections.app, @@ -8140,6 +8148,16 @@ export function createPluginStore(options: PluginStoreOptions) { * retirements of the same person revoke in the same order and write their rows in the same * order. */ + /* + * NO PROVIDER PREDICATE HERE, DELIBERATELY, AND THAT IS NOT AN OVERSIGHT. The sweep below + * revokes through the ACTIVE broker, so a row filed under another provider would be a + * revocation requested at the wrong vendor, which would not recognise the connection, and an + * audit row claiming `vendorRevocationRequested` about access that was never removed. What + * keeps that unreachable is the startup activation refusal (`assertActivatable`, called from + * `server/src/index.ts`): a deployment holding both providers' rows cannot boot. The provider + * sunset flow relaxes that refusal, and whoever builds it must add the `brokerProvider` + * predicate here. + */ const brokered = await database .select({ toolkit: brokeredConnections.app }) .from(brokeredConnections) From 28b13e4aef7626a75aa265dc6dc42bfbc08f6a26 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 18:06:01 +0200 Subject: [PATCH 110/232] fix(arcade): match a connection on the provider INSTANCE, not the provider TYPE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connectionsFor` compared `authProviderOf`'s answer against a user-connection row's `provider_id`. Measured against the live API, those are two different things wearing one field name: - on a tool listing, `requirements.authorization.provider_id` — what `authProviderOf` reads — is the provider TYPE: `"google"`; - on a user-connection row, `provider_id` is the provider INSTANCE id, the operator's configuration of that type: `"google-openbot"`, `"arcade-google"`, `"cpk-poc"`. On an auth-provider config the two sit side by side — `id` is the INSTANCE and `provider_id` is the TYPE — which is the bridge this now reads. Verbatim from the live project: {"id":"cpk-poc", "provider_id":"github", "type":"oauth2"} {"id":"google-openbot", "provider_id":"google", "type":"oauth2"} {"id":"arcade-google", "provider_id":"google", "type":"oauth2"} A connection row carries no vendor-type field at all — its `provider_type` is `"oauth2"`, the auth scheme — so the instance id is the only link to the vendor. `"google" !== "google-openbot"`, so the comparison matched ZERO rows on every real deployment, Arcade's own defaults included: `isConnected` always false, `accountName` always null, and `revoke` answering false without spending a delete, so the vendor connection was never actually revoked and the sibling cascade ended nothing. `authProviderInstances` now expands the type into the SET of configured instance ids of that type — a set, because `arcade-google` and `google-openbot` are both `google` and a person may hold connections under either — read once per call and paged with `listApps`' guards. A row under an instance the config list does not name does not match: fail-closed. An unresolved toolkit still answers no rows with no fallback to the toolkit name, a malformed listing still throws, and the case-insensitive fold stands. Every fixture in the tree spelled the two sides alike, which is why nothing caught it; the connection fixtures and the journey's vendor now spell the instance differently from the type, as the live API does. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 128 +++++++++-- .../tests/arcade-adapter-connections.test.ts | 198 +++++++++++++++--- server/tests/arcade-adapter.test.ts | 23 +- .../tests/arcade-journey.integration.test.ts | 35 +++- 4 files changed, 339 insertions(+), 45 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index b47dda299..702596a93 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -216,35 +216,139 @@ const FIELDS_REFUSAL = type ConnectionRow = Arcade.Admin.UserConnectionResponse; +/** Page size for the auth-provider configuration listing. A short page ends it, as `listApps`. */ +const AUTH_PROVIDER_PAGE_SIZE = 100; +/** A runaway guard, not a bound. */ +const AUTH_PROVIDER_MAX_PAGES = 100; + +/** + * The INSTANCE ids of every auth provider this project has configured of one TYPE. + * + * THERE ARE THREE THINGS AND THEY NEED THREE WORDS. `provider` in this codebase is the BROKER + * (`arcade`); `authProvider` is the vendor's OAuth provider TYPE (`google`); and this is the third, + * the provider INSTANCE — an operator's configuration OF a type, which Arcade keys by its own id + * (`google-openbot`, `arcade-google`, `cpk-poc`). On an auth-provider config `id` is the INSTANCE + * and `provider_id` is the TYPE. Verbatim from the live project: + * + * ``` + * {"id":"cpk-poc", "provider_id":"github", "type":"oauth2", "status":"active"} + * {"id":"google-openbot", "provider_id":"google", "type":"oauth2", "status":"active"} + * {"id":"arcade-google", "provider_id":"google", "type":"oauth2", "status":"active"} + * ``` + * + * A SET AND NEVER ONE ID: `arcade-google` and `google-openbot` are both of type `google`, and a + * person may hold a connection under either. Reducing to one would drop real connections. + * + * PAGED, AND A MALFORMED LISTING THROWS, on `listApps`' argument and with its guards: a truncated + * config list would silently answer "not connected" for whoever is held under an instance that fell + * off the end. Folded to lower case on the way in, so the comparison is Amendment 16's. + */ +async function authProviderInstances( + client: Arcade, + authProvider: string, +): Promise> { + const wanted = authProvider.toLowerCase(); + const instances = new Set(); + let seen = 0; + let total: number | undefined; + let ended = false; + for (let page = 0; page < AUTH_PROVIDER_MAX_PAGES; page += 1) { + const answer = await client.admin.authProviders.list({ + query: { limit: AUTH_PROVIDER_PAGE_SIZE, offset: seen }, + }); + const items = (answer as { items?: unknown } | null)?.items; + if (!Array.isArray(items)) { + throw new Error( + `Arcade did not answer with an auth-provider list, so the configurations of ${authProvider} its connections are held under could not be read.`, + ); + } + for (const item of items) { + const config = record(item); + const instance = textOf(config?.id); + if (instance && textOf(config?.provider_id)?.toLowerCase() === wanted) { + instances.add(instance.toLowerCase()); + } + } + seen += items.length; + const reported = (answer as { total_count?: unknown }).total_count; + if (typeof reported === "number") total = reported; + if (items.length < AUTH_PROVIDER_PAGE_SIZE) { + ended = true; + break; + } + } + if (!ended) { + throw new Error( + `Arcade's auth-provider list never ended after ${AUTH_PROVIDER_MAX_PAGES} pages, so it is not trusted.`, + ); + } + if (total !== undefined && seen < total) { + throw new Error( + `Arcade listed ${seen} of ${total} auth providers, so its configuration list is incomplete.`, + ); + } + return instances; +} + /** * The connections an account holds under a toolkit's AUTH PROVIDER. * - * FILTERED ON THE PROVIDER, NOT THE TOOLKIT NAME. A row's `provider_id` is the auth-provider id, - * which equals the toolkit only when an operator happened to name it so (`Github`, `Asana`, - * `Attio`); one Google connection serves `Gmail` and `GoogleCalendar` both. So the toolkit is - * resolved through `authProviderOf`, which is where that mapping lives, and the rows are compared - * to THAT, case-insensitively (Amendment 16's client-side fold stands). The provider is resolved - * ONCE per call, never per row. + * FILTERED ON THE PROVIDER, NOT THE TOOLKIT NAME — AND NOT ON THE PROVIDER TYPE EITHER. Both halves + * of that are measured, and the second is why this reads a third listing rather than comparing two + * strings. + * + * THE TOOLKIT NAME IS THE WRONG SPELLING because a provider equals the toolkit only when an + * operator happened to name it so (`Github`, `Asana`, `Attio`); one Google connection serves + * `Gmail` and `GoogleCalendar` both. So the toolkit is resolved through `authProviderOf`, which is + * where that mapping lives. + * + * AND `authProviderOf`'S ANSWER IS ALSO THE WRONG SPELLING FOR A CONNECTION ROW, because + * `provider_id` MEANS TWO DIFFERENT THINGS ON THE TWO OBJECTS. Measured against the live API: + * + * - on a tool listing, `requirements.authorization.provider_id` — what `authProviderOf` reads — is + * the provider TYPE: `"google"`; + * - on a user-connection row, `provider_id` is the provider INSTANCE id, the operator's + * configuration: `"google-openbot"`, `"arcade-google"`, `"cpk-poc"`. + * + * A connection row's full key set, verbatim, carries NO vendor-type field at all: `id`, + * `provider_type`, `connection_id`, `connection_status`, `scopes`, `user_id`, `provider_id`, + * `provider_description`, `provider_user_info` — and `provider_type` is `"oauth2"`, the auth SCHEME + * rather than the vendor. The instance id is the ONLY link to the vendor. So `"google"` never + * equals `"google-openbot"`, a direct comparison matches nothing on any real deployment — with + * Arcade's own default providers too, which are `arcade-google` and `arcade-github` and never the + * bare type — and `isConnected` reads false, `accountName` null, and `revoke` false WITHOUT + * SPENDING A DELETE, so nothing is ever revoked at the vendor. Do not "simplify" this back: every + * fixture that spells the two sides alike hides it. + * + * The bridge is {@link authProviderInstances}: the type `authProviderOf` answered is expanded, once + * per call, into the SET of configured instance ids of that type, and a row matches when its + * `provider_id` is in that set (case-insensitively — Amendment 16's client-side fold stands). Both + * the provider and the configuration list are read ONCE per call, never per row. + * + * A ROW UNDER AN INSTANCE THE CONFIG LIST DOES NOT NAME DOES NOT MATCH — a provider configuration + * deleted while a connection outlives it. That is fail-closed: the row is left alone rather than + * deleted on a guess about which vendor it belongs to. * * AN UNRESOLVED TOOLKIT (`null`) ANSWERS AS NO ROWS and does NOT fall back to the toolkit name: a * fallback would silently restore the defect under a different spelling, invisibly against every * fixture in the tree. Callers then answer `isConnected` false, `revoke` false (no delete spent), - * `accountName` null. A malformed listing is different: `authProviderOf` THROWS, and that - * propagates, onto the Disconnect path included. + * `accountName` null. A malformed listing is different: `authProviderOf` and + * {@link authProviderInstances} both THROW, and that propagates, onto the Disconnect path included. */ async function connectionsFor( client: Arcade, vendorUserId: string, toolkit: string, ): Promise { - const provider = await authProviderOf(client, toolkit); - if (provider === null) return []; - const wanted = provider.toLowerCase(); + const authProvider = await authProviderOf(client, toolkit); + if (authProvider === null) return []; + const instances = await authProviderInstances(client, authProvider); const rows: ConnectionRow[] = []; for await (const row of client.admin.userConnections.list({ user_id: vendorUserId, })) { - if (textOf(row.provider_id)?.toLowerCase() === wanted) rows.push(row); + const instance = textOf(row.provider_id)?.toLowerCase(); + if (instance !== undefined && instances.has(instance)) rows.push(row); } return rows; } diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index bf8d75d02..892867c33 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -16,6 +16,11 @@ const toolsProto = (): Methods => new Arcade({ apiKey: "never-dialled" }).tools, ) as Methods; +const authProvidersProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).admin.authProviders, + ) as Methods; + const restores: (() => void)[] = []; function stub( name: string, @@ -39,17 +44,26 @@ const account: AccountRef = { vendorUserId: "vendor-u1", }; -const row = (n: number, provider: string, info?: unknown) => ({ +/** + * One user-connection row, whose `provider_id` is a provider INSTANCE id and NEVER a provider type. + * + * That is the live shape, and it is the whole subject of this file: on a tool listing + * `provider_id` is the TYPE (`google`), on a connection row it is the operator's CONFIGURATION of + * that type (`google-openbot`, `arcade-google`, `cpk-poc`). A fixture spelling both sides alike — + * which every fixture here once did — makes the two meanings coincide and hides a comparison that + * matches nothing on any real deployment. + */ +const row = (n: number, instance: string, info?: unknown) => ({ id: `at_${n}`, connection_id: `ac_${n}`, - provider_id: provider, + provider_id: instance, provider_user_info: info, }); /** - * What the toolkit's listing says its auth provider is, keyed by the toolkit asked about. A toolkit - * absent from the map is answered by a listing that names no provider, i.e. UNRESOLVED. Counts the - * listing reads so a test can pin that a call resolves once. + * What the toolkit's listing says its auth provider TYPE is, keyed by the toolkit asked about. A + * toolkit absent from the map is answered by a listing that names no provider, i.e. UNRESOLVED. + * Counts the listing reads so a test can pin that a call resolves once. */ function providers(byToolkit: Record) { const asked: unknown[] = []; @@ -78,6 +92,42 @@ function providers(byToolkit: Record) { return asked; } +/** + * The project's configured auth providers, verbatim in shape from the live project: `id` is the + * INSTANCE and `provider_id` is the TYPE. Two instances share the type `google` on purpose — an + * operator's own and one of Arcade's defaults — because a person may hold a connection under + * either and reducing the type to one instance would drop one of them. + */ +const CONFIGS = [ + { id: "cpk-poc", provider_id: "github", type: "oauth2", status: "active" }, + { + id: "google-openbot", + provider_id: "google", + type: "oauth2", + status: "active", + }, + { + id: "arcade-google", + provider_id: "google", + type: "oauth2", + status: "active", + }, +]; + +/** Counts the configuration reads, so a test can pin that a call reads them once. */ +function configs(items: unknown[] = CONFIGS) { + const asked: unknown[] = []; + stub( + "list", + (options) => { + asked.push(options); + return Promise.resolve({ items, total_count: items.length }); + }, + authProvidersProto(), + ); + return asked; +} + function listing(rows: unknown[]) { stub("list", () => (async function* () { @@ -86,20 +136,22 @@ function listing(rows: unknown[]) { ); } -/** The world every earlier case lived in: each provider is named after its own toolkit. */ -const NAMED_AFTER_TOOLKIT = { +/** The TYPE each toolkit's listing names. Nothing here is an instance id. */ +const TOOLKIT_TYPES = { Github: "github", GitHub: "github", Google: "google", + Gmail: "google", }; describe("arcade adapter connection lifecycle", () => { beforeEach(() => { - providers(NAMED_AFTER_TOOLKIT); + providers(TOOLKIT_TYPES); + configs(); }); test("isConnected is true on a non-empty list and false on an empty one", async () => { - const rows: unknown[] = [row(1, "github")]; + const rows: unknown[] = [row(1, "cpk-poc")]; listing(rows); const { broker } = createArcadeClient("never-dialled"); expect(await broker.isConnected({ account, toolkit: "Github" })).toBe(true); @@ -110,7 +162,7 @@ describe("arcade adapter connection lifecycle", () => { }); test("isConnected ignores another app's connection", async () => { - listing([row(1, "google")]); + listing([row(1, "google-openbot")]); const { broker } = createArcadeClient("never-dialled"); expect(await broker.isConnected({ account, toolkit: "Github" })).toBe( false, @@ -118,7 +170,7 @@ describe("arcade adapter connection lifecycle", () => { }); test("delete is passed the ac_ connection_id, never the at_ id", async () => { - listing([row(7, "github")]); + listing([row(7, "cpk-poc")]); const deleted: unknown[] = []; stub("delete", (id) => { deleted.push(id); @@ -142,7 +194,7 @@ describe("arcade adapter connection lifecycle", () => { }); test("revoke answers false on nothing-found and deletes nothing", async () => { - listing([row(1, "google")]); + listing([row(1, "google-openbot")]); const deleted: unknown[] = []; stub("delete", (id) => { deleted.push(id); @@ -154,7 +206,7 @@ describe("arcade adapter connection lifecycle", () => { }); test("revoke THROWS when one of several deletes fails, after trying them all", async () => { - listing([row(1, "github"), row(2, "github"), row(3, "github")]); + listing([row(1, "cpk-poc"), row(2, "cpk-poc"), row(3, "cpk-poc")]); const tried: unknown[] = []; stub("delete", (id) => { tried.push(id); @@ -176,9 +228,9 @@ describe("arcade adapter connection lifecycle", () => { test("accountName never answers another app's name", async () => { listing([ - row(1, "github", {}), - row(2, "google", { name: "Grace" }), - row(3, "github", { name: "Ada" }), + row(1, "cpk-poc", {}), + row(2, "google-openbot", { name: "Grace" }), + row(3, "cpk-poc", { name: "Ada" }), ]); const { broker } = createArcadeClient("never-dialled"); expect(await broker.accountName({ account, toolkit: "GitHub" })).toBe( @@ -190,14 +242,22 @@ describe("arcade adapter connection lifecycle", () => { }); test("accountName is null, not the second app's name, when the first carries none", async () => { - listing([row(1, "github", {}), row(2, "google", { name: "Grace" })]); + listing([ + row(1, "cpk-poc", {}), + row(2, "google-openbot", { name: "Grace" }), + ]); const { broker } = createArcadeClient("never-dialled"); expect(await broker.accountName({ account, toolkit: "Github" })).toBeNull(); }); - test("a Gmail row under provider `google` is FOUND by all three members", async () => { - providers({ Gmail: "google" }); - listing([row(4, "google", { name: "Grace" })]); + /* + * THE DEFECT THIS FILE EXISTS FOR, AT ITS THREE MEMBERS. `Gmail` names the TYPE `google`; the + * connection is held under the INSTANCE `google-openbot`. A comparison of the two raw strings + * finds nothing — and `revoke` then answers false WITHOUT SPENDING A DELETE, so the vendor + * connection outlives the Disconnect that was supposed to end it. + */ + test("a Gmail connection under the custom instance `google-openbot` is FOUND by all three members", async () => { + listing([row(4, "google-openbot", { name: "Grace" })]); const deleted: unknown[] = []; stub("delete", (id) => { deleted.push(id); @@ -208,19 +268,82 @@ describe("arcade adapter connection lifecycle", () => { expect(await broker.isConnected({ account, toolkit })).toBe(true); expect(await broker.accountName({ account, toolkit })).toBe("Grace"); expect(await broker.revoke({ account, toolkit })).toBe(true); + // The `ac_…` connection_id, never the `at_…` row id. expect(deleted).toEqual(["ac_4"]); }); - test("the provider is compared case-insensitively", async () => { - providers({ Gmail: "Google" }); - listing([row(4, "google", { name: "Grace" })]); + test("a Gmail connection under Arcade's DEFAULT instance `arcade-google` is found too", async () => { + listing([row(5, "arcade-google", { name: "Ada" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Gmail"; + expect(await broker.isConnected({ account, toolkit })).toBe(true); + expect(await broker.accountName({ account, toolkit })).toBe("Ada"); + expect(await broker.revoke({ account, toolkit })).toBe(true); + expect(deleted).toEqual(["ac_5"]); + }); + + test("BOTH instances of one type are matched; neither is dropped for the other", async () => { + listing([row(4, "google-openbot"), row(5, "arcade-google")]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.revoke({ account, toolkit: "Gmail" })).toBe(true); + expect(deleted.sort()).toEqual(["ac_4", "ac_5"]); + }); + + test("an UNRELATED instance (`cpk-poc`, type github) is not a Gmail connection", async () => { + listing([row(1, "cpk-poc", { name: "Ada" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Gmail"; + expect(await broker.isConnected({ account, toolkit })).toBe(false); + expect(await broker.accountName({ account, toolkit })).toBeNull(); + expect(await broker.revoke({ account, toolkit })).toBe(false); + expect(deleted).toEqual([]); + }); + + test("the TYPE itself is not an instance id, so a row spelled `google` is not matched", async () => { + // Nothing anywhere falls back to comparing the raw type against a row. + listing([row(6, "google", { name: "Grace" })]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(false); + }); + + test("an instance the configuration list does not name is fail-closed, not deleted", async () => { + // A provider configuration deleted while a connection outlived it. + listing([row(8, "retired-google", { name: "Grace" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.revoke({ account, toolkit: "Gmail" })).toBe(false); + expect(deleted).toEqual([]); + }); + + test("both the type and the instance are compared case-insensitively", async () => { + providers({ Gmail: "GOOGLE" }); + configs([{ id: "Google-OpenBot", provider_id: "Google" }]); + listing([row(4, "google-openbot", { name: "Grace" })]); const { broker } = createArcadeClient("never-dialled"); expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); }); test("a different provider on the same identity is still excluded", async () => { - providers({ Gmail: "google" }); - listing([row(1, "github", { name: "Ada" }), row(2, "google", {})]); + listing([row(1, "cpk-poc", { name: "Ada" }), row(2, "google-openbot", {})]); const deleted: unknown[] = []; stub("delete", (id) => { deleted.push(id); @@ -252,19 +375,38 @@ describe("arcade adapter connection lifecycle", () => { test("a malformed listing THROWS out of the lifecycle members rather than answering no rows", async () => { stub("list", () => Promise.resolve({ items: "nope" }), toolsProto()); - listing([row(1, "gmail")]); + listing([row(1, "google-openbot")]); const { broker } = createArcadeClient("never-dialled"); await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( /action list/, ); }); - test("each call resolves the provider once, however many rows there are", async () => { + test("a malformed auth-provider listing THROWS rather than answering no rows", async () => { + stub( + "list", + () => Promise.resolve({ items: "nope" }), + authProvidersProto(), + ); + listing([row(1, "google-openbot")]); + const { broker } = createArcadeClient("never-dialled"); + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + /auth-provider list/, + ); + }); + + test("each call resolves the provider and its configurations once, however many rows", async () => { const asked = providers({ Gmail: "google" }); - listing([row(1, "google"), row(2, "google"), row(3, "google")]); + const read = configs(); + listing([ + row(1, "google-openbot"), + row(2, "google-openbot"), + row(3, "arcade-google"), + ]); stub("delete", () => Promise.resolve()); const { broker } = createArcadeClient("never-dialled"); await broker.revoke({ account, toolkit: "Gmail" }); expect(asked).toHaveLength(1); + expect(read).toHaveLength(1); }); }); diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index 0d52918f6..325782ca9 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -131,6 +131,10 @@ describe("createArcadeClient", () => { Object.getPrototypeOf( new Arcade({ apiKey: "never-dialled" }).admin.userConnections, ) as Methods; + const authProvidersProto = (): Methods => + Object.getPrototypeOf( + new Arcade({ apiKey: "never-dialled" }).admin.authProviders, + ) as Methods; const account = { vendorUserId: "u1" } as never; test("ensureAuthConfig answers not-needed in every case", async () => { @@ -174,7 +178,7 @@ describe("createArcadeClient", () => { for (const r of rows) yield r; })(), ); - // The toolkit's own listing names the provider its connections are held under. + // The toolkit's own listing names the provider TYPE its connections are held under. stub(toolsProto(), "list", () => Promise.resolve({ items: [ @@ -185,18 +189,29 @@ describe("createArcadeClient", () => { ], }), ); + // And the project's configured providers map that type to its INSTANCE ids, which is what a + // connection row carries. See `connectionsFor`. + stub(authProvidersProto(), "list", () => + Promise.resolve({ + items: [{ id: "cpk-poc", provider_id: "github" }], + total_count: 1, + }), + ); const { broker } = createArcadeClient("never-dialled"); const ask = () => broker.accountName({ account, toolkit: "Github" }); rows.push({ - provider_id: "github", + provider_id: "cpk-poc", provider_user_info: { name: "Ada", login: "ada1" }, }); expect(await ask()).toBe("Ada"); rows.length = 0; - rows.push({ provider_id: "github", provider_user_info: { login: "ada1" } }); + rows.push({ + provider_id: "cpk-poc", + provider_user_info: { login: "ada1" }, + }); expect(await ask()).toBe("ada1"); rows.length = 0; - rows.push({ provider_id: "github", provider_user_info: {} }); + rows.push({ provider_id: "cpk-poc", provider_user_info: {} }); expect(await ask()).toBeNull(); }); diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index adebc6da7..71a7f2fc0 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -105,6 +105,17 @@ const DELETE_EVENT = `${DIARY}.DeleteEvent`; */ const AUTH_PROVIDER = "google"; +/** + * AND THE CONFIGURATION OF IT THE CONNECTIONS ARE ACTUALLY HELD UNDER, which is a THIRD name. + * + * A tool listing's `provider_id` is the provider TYPE (`google`); a user-connection row's + * `provider_id` is the provider INSTANCE — an operator's configuration of that type, which is what + * the live project answers (`google-openbot`, `arcade-google`, `cpk-poc`). Spelled differently + * here on purpose: a journey whose vendor used one spelling on both sides would be green against + * an adapter that compares the two raw strings and finds nothing on every real deployment. + */ +const AUTH_PROVIDER_INSTANCE = `${AUTH_PROVIDER}-openbot`; + const ADMIN = `arcade-journey-${suite}`; const BOT = `bot-${suite}`; const APP_URL = "https://app.example"; @@ -258,7 +269,7 @@ function holdConnection(userId: string) { vendorConnections.push({ id: `at_${n}`, connection_id: `ac_${n}`, - provider_id: AUTH_PROVIDER, + provider_id: AUTH_PROVIDER_INSTANCE, user_id: userId, provider_user_info: { name: `Account ${n}` }, }); @@ -375,6 +386,28 @@ function installVendor() { }, ); + /* + * THE PROJECT'S CONFIGURED AUTH PROVIDERS, in the live shape: `id` is the INSTANCE and + * `provider_id` is the TYPE. This is the listing that bridges a toolkit's type to the instance + * ids its connection rows carry; `cpk-poc` is here so the bridge has something to exclude. + */ + stub( + (client) => client.admin.authProviders, + "list", + () => + Promise.resolve({ + items: [ + { id: "cpk-poc", provider_id: "github", type: "oauth2" }, + { + id: AUTH_PROVIDER_INSTANCE, + provider_id: AUTH_PROVIDER, + type: "oauth2", + }, + ], + total_count: 2, + }), + ); + stub( (client) => client.admin.userConnections, "list", From de986965b71c1d057f2cedca75ce40207b0d2547 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 18:11:51 +0200 Subject: [PATCH 111/232] docs(arcade): say which object a `provider_id` is on, and record the live run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All of this was measured in the first live run against the Arcade API on this branch, from this deployment's own code. `provider_id` names three different things, and nothing in the tree said so: on a tool listing it is the provider TYPE (`google`), on a user-connection row the provider INSTANCE id (`google-openbot`, `cpk-poc`), and on an auth-provider config the two sit side by side, `id` being the instance and `provider_id` the type. A connection row carries no vendor-type field at all — its `provider_type` is `"oauth2"`, the auth scheme — so the instance id is the only link to the vendor. The `authProviderOf` docblock claimed the member answered with a configuration; it answers with the type, which is what made the comparison fixed in 28b13e4a look right. The operator page gains that distinction with the verbatim config and connection-row shapes, and notes that it bit this deployment once: a connection that existed read as not connected. Moved out of "unproven" on that page, all now measured: - sibling revocation — one `google` provider serves `Gmail` and `GoogleCalendar`, so disconnecting one app can end another, in production; - Google's per-action isolation — 0 of 30 scope-carrying `Gmail` actions authorized before one real consent to `Gmail.GetEmail`, 11 of 30 after, and nothing beyond the granted scopes; - the wrong-user confirm shape — a generic `400 coordinator_error "Bad request"` naming no mismatch, so a wrong-account return presents as the vendor-unavailable refusal rather than its own sentence. Added as unproven: Google's "Use this client for an AI-powered agent" checkbox, a self-declaration that puts the client under a separate admin-policy surface and which Google advises keeping apart from human-workflow clients. OpenBot's brokered calls are agent-initiated, so a deployer should choose deliberately; it was left unticked today to keep a policy variable out of the measurement. Co-Authored-By: Claude Opus 5 (1M context) --- docs/plugins/arcade.md | 100 +++++++++++++++++++++++++++++------ server/src/plugins/broker.ts | 33 +++++++++--- 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/docs/plugins/arcade.md b/docs/plugins/arcade.md index 0b4a616fe..69ddc7968 100644 --- a/docs/plugins/arcade.md +++ b/docs/plugins/arcade.md @@ -69,6 +69,15 @@ project in order to connect their mailbox — so a default provider cannot serve Arcade's admin API can create the OAuth provider, so that part can be scripted. +> **Decide about the agentic-client checkbox deliberately — unproven.** Google's console offers +> _"Use this client for an AI-powered agent"_ on an OAuth client. It is a self-declaration, and +> ticking it puts that client under a separate administrative-policy surface; Google's own advice is +> to register separate client ids for human workflows and agent workflows. Every brokered call +> OpenBot makes is agent-initiated, so this is a choice a deployer should make on purpose rather +> than leave at its default. **It was not exercised:** the client used for the measurements on this +> page was registered with the box left unticked, deliberately, to keep a policy variable out of the +> measurement. What ticking it changes in practice is therefore not known here. + ### The verifier URL is the one dashboard-only step **The verifier URL cannot be automated.** It is set by hand in the Arcade dashboard, on the project, @@ -130,6 +139,12 @@ issues per-request scopes. Measured, through a completed consent, from a clean b | Google (`Gmail`) | `gmail.readonly` vs `gmail.send` vs `gmail.compose` | **11 of 30** | yes | | GitHub (via a GitHub App) | **none, for any action** | **43 of 43** | no | +The Google row has since been re-measured through this deployment's own code against the live API, +from a clean baseline and one real consent: before consenting, **0 of 30** scope-carrying `Gmail` +actions were authorized; after consenting to a single action (`Gmail.GetEmail`), **11 of 30** — and +**nothing outside the scopes that consent granted**. Per-action isolation on Google holds, measured +twice now and no longer resting on the throwaway harness that first found it. + On Google, consenting to a read authorizes the reads that share its scope and nothing that sends. On GitHub, consenting to `Github.CountStargazers` — **a read** — flipped all 43 actions to authorized, **the writes and the destructive one included**, because a GitHub App's permissions are @@ -156,11 +171,56 @@ consenting to a broad scope does not hand any Bot more actions; a Bot with no gr cannot call it however much the account allows. On a provider that cannot narrow scopes (GitHub above), the tool grant is therefore the entire narrowing — the same position as Composio's. +## `provider_id` means three different things + +An operator reading an Arcade dashboard, or an API response, or a refusal this deployment quotes, +will meet the field `provider_id` on three different objects — and it means something different on +each. _Measured_ against a live project: + +| Where you are reading it | What it is | Example | +| --------------------------------------------------- | ------------------------------------------------ | ------------------------------------ | +| A **tool** listing, `requirements.authorization` | the provider **type** — the vendor | `google` | +| A **user connection** row | the provider **instance** — a configuration | `google-openbot`, `cpk-poc` | +| An **auth provider** config | `id` is the instance, `provider_id` is the type | `id: arcade-google` of type `google` | + +The auth-provider config is the only bridge between the other two. Verbatim, from a live project: + +``` +{"id":"cpk-poc", "provider_id":"github", "type":"oauth2", "status":"active"} +{"id":"google-openbot", "provider_id":"google", "type":"oauth2", "status":"active"} +{"id":"arcade-google", "provider_id":"google", "type":"oauth2", "status":"active"} +``` + +A connection row carries **no vendor-type field at all**. Its full key set, verbatim: + +``` +id, provider_type, connection_id, connection_status, scopes, user_id, provider_id, +provider_description, provider_user_info +``` + +`provider_type` there is `"oauth2"` — the auth **scheme**, not the vendor. So the instance id is the +only thing on a connection that says which vendor it belongs to, and the only way from a tool's +`google` to a person's row is through the list of configured providers of type `google`. + +**This bit this deployment once.** A connection lookup compared a tool's type against a row's +instance — `"google"` against `"google-openbot"` — and so matched nothing on every real deployment, +Arcade's own default providers included, since those are named `arcade-google` and `arcade-github` +and never the bare type. It did not present as an error: a connection that existed read as +**not connected**, with no account name beside it. It is fixed, and it is written down here because +the same confusion is waiting for whoever next reads two Arcade objects side by side. **If a +connection you can see in Arcade's dashboard looks absent in OpenBot, check that the provider the +person connected under is one this project has configured** — a provider configuration deleted while +somebody's connection outlived it reads the same way, and deliberately so: OpenBot leaves a row it +cannot attribute alone rather than guess which vendor it belongs to. + ## Disconnecting one app may end another -Arcade's connections are per **auth provider**, not per app. One Google connection serves `Gmail` -and `GoogleCalendar` both, and **no narrower instrument exists** — there is nothing to disconnect -that ends only one of them. +Arcade's connections are per **auth provider**, not per app, and the sharing is _measured_ rather +than inferred: on a live project the provider behind both `Gmail` and `GoogleCalendar` is the single +type `google` (`{"Gmail":["google"],"GoogleCalendar":["google"]}`), named after neither toolkit. One +Google connection serves them both, and **no narrower instrument exists** — there is nothing to +disconnect that ends only one of them. **Somebody disconnecting Gmail can end their Calendar**, in +production, today. So before somebody confirms a disconnect, OpenBot names the apps that genuinely share the connection and would end with it. _Genuinely_ is exact: siblings are computed on the **pair (vendor @@ -195,21 +255,29 @@ cached here and compared later: it is read **live from Arcade** — whether the an action is asked of Arcade when the page is drawn, so the answer on screen is Arcade's current one. A connection that works and has never been "checked" is the normal state, not a fault. +## A consent coming back for the wrong person + +A consent that completes but belongs to a different identity than the attempt it is returning to is +refused, and nothing is written either way. **What Arcade answers in that case carries no reason**: +_measured_, confirming a return as the wrong user answers a generic `400`, `coordinator_error`, +message `"Bad request"`, with nothing in it that names an identity mismatch. + +So the refusal a person sees is the ordinary **vendor-unavailable** one, not a sentence about the +wrong account. That is a worse message and nothing more: the attempt row is left untouched, and +the returning authorization is in any case compared to the id this deployment minted, which is the +check that actually binds the return leg to the attempt. An operator shown a vendor-unavailable +refusal on a consent that seemed to complete should consider the wrong-account case among the +causes, because the vendor will not say. + ## What is unproven -Nothing in this release has dialled the live Arcade API from this deployment's own code. The -measurements above were taken through completed consents against a live project; the integration -that acts on them is verified against recorded fixtures. Specifically: - -- **The `user_mismatch` error shape is assumed.** Arcade's SDK declares no such error, so what the - deployment recognises as `user_mismatch` — the vendor's answer when the returning consent does - not belong to the identity the attempt was started for — is the shape expected, not one observed. If a consent is refused for that reason and the page shows a generic failure instead of - its own sentence, this is where to look. -- **`provider_id` is read as the auth-provider id, not the toolkit name.** That reading is inferred - from measurements and from a live refusal envelope carrying `"provider_id": "cpk-poc"`. **No - fixture has a toolkit whose provider is named differently from it**, so it has not been measured - on a Google-family toolkit — where `Gmail` and `GoogleCalendar` share one provider — and the - sibling computation above rests on it. +The measurements above were taken against a live Arcade project, most of them through this +deployment's own code. What remains unproven is listed here, and nothing else on the page is. + +- **The agentic-client checkbox was not exercised.** Google's _"Use this client for an AI-powered + agent"_ declaration was deliberately left unticked for the measurements, so that it was not a + variable in them. Its effect on consent, scopes or policy here is not known; see the deployer note + above. - **The wording of Arcade's dashboard is not described here.** This page says to set the project's verifier to the address above and does not name the dashboard's fields or screens, which were not checked against it and may change. The address itself is this deployment's. diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 70259bb8c..2107882c4 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -516,14 +516,31 @@ export type ConnectedAppBroker = { * vendor does not say. * * IT IS NOT THE TOOLKIT NAME, AND THE COINCIDENCE IS NOT THE RULE. Arcade keys a connection on - * `(user_id, provider_id)`, and `provider_id` names an auth-provider CONFIGURATION rather than a - * toolkit: one Google connection serves `Gmail` and `GoogleCalendar` both, and a measured refusal - * envelope carries `"provider_id": "cpk-poc"`, which is an operator's name for a provider they - * registered. It equals the toolkit slug only where an operator named it that way — which - * `Github`, `Asana` and `Attio` all happen to do, and those are every toolkit in this tree's - * fixtures and stubs. So a derivation from the toolkit name, or a fallback to it where the vendor - * is silent, would pass every test here and be wrong about the deployment it was written for. - * `null` is the honest answer, and its callers leave such an app out rather than guess. + * `(user_id, provider_id)` rather than on a toolkit: one Google provider serves `Gmail` and + * `GoogleCalendar` both. A provider equals the toolkit slug only where an operator named it that + * way — which `Github`, `Asana` and `Attio` all happen to do, and those are every toolkit in this + * tree's fixtures and stubs. So a derivation from the toolkit name, or a fallback to it where the + * vendor is silent, would pass every test here and be wrong about the deployment it was written + * for. `null` is the honest answer, and its callers leave such an app out rather than guess. + * + * WHAT THIS MEMBER ANSWERS WITH IS THE PROVIDER TYPE, AND `provider_id` NAMES THREE DIFFERENT + * THINGS ON THREE ARCADE OBJECTS. Measured against the live API: + * + * - on a TOOL LISTING, `requirements.authorization.provider_id` is the provider TYPE (`google`) — + * this is the one this member reads, and the one it returns; + * - on a USER-CONNECTION ROW, `provider_id` is the provider INSTANCE id, an operator's + * configuration OF a type (`google-openbot`, `arcade-google`, `cpk-poc`). A refusal envelope's + * `"provider_id": "cpk-poc"` is an instance too; + * - on an AUTH-PROVIDER CONFIG the two sit side by side — `id` is the INSTANCE and `provider_id` + * is the TYPE — which is the only bridge between the first two, because a connection row + * carries no vendor-type field at all: its `provider_type` is `"oauth2"`, the auth SCHEME. + * + * SO A CALLER MUST NOT COMPARE THIS ANSWER TO A CONNECTION ROW'S `provider_id`. `"google"` never + * equals `"google-openbot"`, so that comparison matched zero rows on every real deployment — + * Arcade's own defaults included, since those are `arcade-google` and `arcade-github` and never + * the bare type — and it read as "connected to nothing" rather than as an error. A caller wanting + * rows expands this type into the SET of configured instance ids of that type first; the Arcade + * adapter's `connectionsFor` is where that is done and why. * * NAMED `authProviderOf` AND NEVER `providerIdOf`. In this codebase `provider` means the BROKER — * {@link BrokerProvider}, `brokered_connections.provider` — and that collision has already been From ffef56df2f5b206c047f1186a4d788c3a69dc798 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 18:13:14 +0200 Subject: [PATCH 112/232] test(arcade): correct the live suite against what the first real run measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arcade live suite ran against the live API for the first time: 9 passed, 3 failed, and not one failure was the vendor's. One was a product defect in the connection filter (fixed in 28b13e4a); the other two, and a duration failure, were defects in this suite. All three are corrected from the measurement rather than from the assumption that preceded it. TIMEOUTS, because bun's per-test default is 5000ms. `next_uri is refused for every value` makes five sequential vendor round-trips and died at 5001.03ms having measured none of them. Every case that dials Arcade now carries an explicit timeout, following the pattern the consent case already used: 60s for a case, five times that for the five-call `next_uri` case, the poll's own bound for `auth.status`, and the same on the `afterAll` cleanup, which also dials. GATE 4 ASSERTED THE WRONG SPELLING, and that is how it hid the product defect. It filtered connections by the provider TYPE, found nothing, and concluded "the filter does not take this provider id". The filter is fine; `provider_id` means two different things: the TYPE on a tool listing (`google`), the INSTANCE on a connection row (`google-openbot`, `cpk-poc`, `arcade-google`), with the auth-provider config the only bridge between them. It now prints the consented connection's own `provider_id` verbatim — the line whose absence made the run ambiguous — resolves the instances through `admin.authProviders.list()`, asserts the connection is found by instance, and measures the case handling against the instance, which is the value a filter is actually given. GATE 2 MEASURED THAT ITS ASSUMPTION IS FALSE, so it now records that instead. Arcade answers a wrong-user `confirm_user` with a generic 400 `coordinator_error` / "Bad request", carrying nothing that identifies a mismatch, so the `{ mismatch: true }` contract is unreachable through the live API today; detecting it would mean reading every bad request as a mismatch. The product keeps that branch — it costs nothing and starts working if the vendor ever distinguishes the case — and the case asserts the measured reality: the vendor refuses, `confirmReturn` throws, and the return leg refuses with `vendor-unavailable`. A UX degradation, not a hole: the leg stays bound by the `auth_id` comparison regardless. Both other outcomes are still printed. Suite only; no product code is touched. The live suite was NOT re-run — it needs a person at a browser. Verified that it still type-checks against the server's config, still skips every case with no key, and that `test:live-arcade` still exits non-zero without one. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-live.test.ts | 703 +++++++++++++++++++------------ 1 file changed, 437 insertions(+), 266 deletions(-) diff --git a/server/tests/arcade-live.test.ts b/server/tests/arcade-live.test.ts index 85b258395..15d0978b7 100644 --- a/server/tests/arcade-live.test.ts +++ b/server/tests/arcade-live.test.ts @@ -10,10 +10,14 @@ import { mintDeploymentVendorUserId } from "../src/plugins/shared-accounts"; /** * The real Arcade, so the shapes this transport is written against are the shapes it gets. * - * WRITTEN, AND NEVER RUN BY WHOEVER WROTE IT. Nothing in this branch had contacted Arcade when this - * file was authored; a live run is a separate, human-authorised step. Every wire fact below was - * measured once by a throwaway harness and is repeated here so it is repeatable rather than - * remembered. + * WRITTEN BLIND, AND SINCE RUN ONCE. Nothing in this branch had contacted Arcade when this file was + * authored; one live run has happened since. It came back 9 passed / 3 failed, and not one was the + * vendor's: `next_uri` died on bun's 5000ms default without measuring anything, GATE 4 asserted the + * wrong spelling of `provider_id` (which is also how it hid a real product defect in the connection + * filter, fixed separately), and GATE 2 asserted an assumption the wire does not support. All three + * are corrected here: the gates now say what was measured, every dialling case carries an explicit + * timeout, and each comment records the verbatim observation rather than the expectation it + * replaced. A live run remains a separate, human-authorised step. * * WHY THIS EXISTS AT ALL. A stub asserts what its author believed; this asserts what the vendor does. * `composio-live.test.ts` is the pattern, and its argument carries over whole. @@ -82,6 +86,25 @@ const CONSENT_TIMEOUT_S = Number( process.env.ARCADE_LIVE_CONSENT_TIMEOUT_S ?? "300", ); +/** + * EVERY CASE THAT DIALS ARCADE CARRIES AN EXPLICIT TIMEOUT, because bun's per-test default is 5000ms + * and a vendor round-trip is nothing like a unit test's. This is measured, not defensive: the first + * live run of this file failed `next_uri is refused for every value` on duration alone — + * `[5001.03ms] ^ this test timed out after 5000ms` — five sequential refusals against a 5s budget, + * so the case measured nothing it was written to measure. The consent case already carried its own + * (it waits through a 300s consent); every other dialling case now does too, to the same pattern: a + * trailing argument to `test`, and a trailing `options` argument to `afterAll`. + * + * Generous rather than tuned. A timeout here is a runaway guard, not a performance assertion — a + * slow but correct vendor must not be reported as a wrong one. + */ +const VENDOR_TIMEOUT_MS = 60_000; + +/** `auth.status` is POLLED, so its bound is the poll's own, not one round-trip's. */ +const STATUS_POLLS = 6; +const STATUS_POLL_INTERVAL_MS = 2_000; +const STATUS_TIMEOUT_MS = STATUS_POLLS * (STATUS_POLL_INTERVAL_MS + 10_000); + /** The Google family: two toolkits that, on Arcade, are expected to share one auth provider. */ const GOOGLE_TOOLKITS = ["Gmail", "GoogleCalendar"]; @@ -180,7 +203,7 @@ describe.skipIf(!live)("Arcade, for real", () => { .catch(() => {}); } } - }); + }, VENDOR_TIMEOUT_MS); test("the throwaway user id is printed before anything is created", () => { say(`ARCADE LIVE user id for this run: ${USER}`); @@ -204,91 +227,110 @@ describe.skipIf(!live)("Arcade, for real", () => { * decides whether D26's sibling code is ever exercised. No offline fixture names a toolkit's * provider differently from the toolkit, which is why only this can tell. */ - test("GATE 1: what provider_id means on Gmail and GoogleCalendar", async () => { - const client = arcade(); - const seen: Record = {}; - for (const toolkit of GOOGLE_TOOLKITS) { - const items = await listToolkit(client, toolkit); + test( + "GATE 1: what provider_id means on Gmail and GoogleCalendar", + async () => { + const client = arcade(); + const seen: Record = {}; + for (const toolkit of GOOGLE_TOOLKITS) { + const items = await listToolkit(client, toolkit); + need( + items.length > 0 ? items : null, + `Arcade listed no ${toolkit} action, so its provider could not be read`, + ); + seen[toolkit] = providersOf(items); + } + const all = new Set(Object.values(seen).flat()); + const sharedAndDistinct = + all.size === 1 && !GOOGLE_TOOLKITS.some((name) => all.has(name)); + const equalToToolkit = GOOGLE_TOOLKITS.every((name) => + seen[name]?.every((p) => p === name), + ); + say( + `GATE 1 provider ids by toolkit: ${JSON.stringify(seen)} -> ` + + (sharedAndDistinct + ? "ONE shared provider, named differently from the toolkits: D26's sibling set IS non-empty in production" + : equalToToolkit + ? "each toolkit names a provider equal to itself: D26's sibling set is empty in production" + : "MIXED: read the ids above and decide by hand"), + ); + // Whichever reading holds, every toolkit must name exactly one provider, or authProviderOf + // answers null and every connection under it reads as absent. + for (const toolkit of GOOGLE_TOOLKITS) { + expect(seen[toolkit]?.length).toBe(1); + } + }, + VENDOR_TIMEOUT_MS, + ); + + test( + "the action to consent to is read from the listing, and the baseline is clean", + async () => { + const client = arcade(); + const items = await listToolkit(client, TOOLKIT, USER); + run.total = items.length; need( items.length > 0 ? items : null, - `Arcade listed no ${toolkit} action, so its provider could not be read`, + `Arcade listed no ${TOOLKIT} action`, ); - seen[toolkit] = providersOf(items); - } - const all = new Set(Object.values(seen).flat()); - const sharedAndDistinct = - all.size === 1 && !GOOGLE_TOOLKITS.some((name) => all.has(name)); - const equalToToolkit = GOOGLE_TOOLKITS.every((name) => - seen[name]?.every((p) => p === name), - ); - say( - `GATE 1 provider ids by toolkit: ${JSON.stringify(seen)} -> ` + - (sharedAndDistinct - ? "ONE shared provider, named differently from the toolkits: D26's sibling set IS non-empty in production" - : equalToToolkit - ? "each toolkit names a provider equal to itself: D26's sibling set is empty in production" - : "MIXED: read the ids above and decide by hand"), - ); - // Whichever reading holds, every toolkit must name exactly one provider, or authProviderOf - // answers null and every connection under it reads as absent. - for (const toolkit of GOOGLE_TOOLKITS) { - expect(seen[toolkit]?.length).toBe(1); - } - }); - - test("the action to consent to is read from the listing, and the baseline is clean", async () => { - const client = arcade(); - const items = await listToolkit(client, TOOLKIT, USER); - run.total = items.length; - need(items.length > 0 ? items : null, `Arcade listed no ${TOOLKIT} action`); - run.action = - process.env.ARCADE_LIVE_ACTION?.trim() || - items.find((item) => { - const verdict = classify(item.metadata); - return verdict.effect === "read" && !verdict.destructive; - })?.qualified_name; - need( - run.action, - `${TOOLKIT} lists no read-only action and ARCADE_LIVE_ACTION is unset`, - ); - // A fresh user id has granted nothing. If anything already reads `met`, the "before" is not a - // baseline and the "after" would measure nothing. - run.baselineMet = metCount(items); - say( - `BLAST RADIUS baseline for ${TOOLKIT}: met ${run.baselineMet} of ${items.length} (action to mint: ${run.action})`, - ); - expect(run.baselineMet).toBe(0); - }); + run.action = + process.env.ARCADE_LIVE_ACTION?.trim() || + items.find((item) => { + const verdict = classify(item.metadata); + return verdict.effect === "read" && !verdict.destructive; + })?.qualified_name; + need( + run.action, + `${TOOLKIT} lists no read-only action and ARCADE_LIVE_ACTION is unset`, + ); + // A fresh user id has granted nothing. If anything already reads `met`, the "before" is not a + // baseline and the "after" would measure nothing. + run.baselineMet = metCount(items); + say( + `BLAST RADIUS baseline for ${TOOLKIT}: met ${run.baselineMet} of ${items.length} (action to mint: ${run.action})`, + ); + expect(run.baselineMet).toBe(0); + }, + VENDOR_TIMEOUT_MS, + ); /** * FENCED FACT 1: `next_uri` IS REFUSED FOR EVERY VALUE, so the adapter never sends it. * Every value must answer 500 `invalid next URI`. If ANY is accepted, the fence is stale: report * it, do not start sending one. + * + * FIVE SEQUENTIAL ROUND-TRIPS, hence a timeout of five times the single-call budget. This case is + * why {@link VENDOR_TIMEOUT_MS} exists: on the first live run it died at 5001ms against bun's + * default, measuring none of the five. */ - test("next_uri is refused for every value", async () => { - const client = arcade(); - const action = need(run.action, "no action was chosen"); - const values = [ - "https://app.example.com/settings/connected-accounts", - `http://localhost:${PORT}/`, - "https://cloud.arcade.dev", - "http://localhost:3000/", - "/relative/path", - ]; - for (const next_uri of values) { - const refusal = await failureOf(() => - client.tools.authorize({ - tool_name: action, - user_id: USER, - next_uri, - }), - ); - expect([next_uri, refusal.status]).toEqual([next_uri, 500]); - expect(normalizeArcadeError(refusal)?.message ?? "").toMatch( - /invalid next uri/i, - ); - } - }); + test( + "next_uri is refused for every value", + async () => { + const client = arcade(); + const action = need(run.action, "no action was chosen"); + const values = [ + "https://app.example.com/settings/connected-accounts", + `http://localhost:${PORT}/`, + "https://cloud.arcade.dev", + "http://localhost:3000/", + "/relative/path", + ]; + for (const next_uri of values) { + const refusal = await failureOf(() => + client.tools.authorize({ + tool_name: action, + user_id: USER, + next_uri, + }), + ); + expect([next_uri, refusal.status]).toEqual([next_uri, 500]); + expect(normalizeArcadeError(refusal)?.message ?? "").toMatch( + /invalid next uri/i, + ); + } + }, + 5 * VENDOR_TIMEOUT_MS, + ); /** * THE CONSENT: mint through OUR adapter, wait for the callback at a local stand-in for the @@ -353,90 +395,135 @@ describe.skipIf(!live)("Arcade, for real", () => { ); /** - * GATE 2 — THE `user_mismatch` WIRE SHAPE, currently ASSUMED (the SDK declares no error shape). + * GATE 2 — THE `user_mismatch` WIRE SHAPE. MEASURED, AND THE ANSWER IS THAT THERE ISN'T ONE. + * + * This case used to assert the ASSUMED shape, `{ mismatch: true }`. The first live run measured + * what Arcade actually answers a wrong-user `confirm_user`, verbatim: + * + * ``` + * GATE 2 raw confirm_user as wrong user: status=400 name=coordinator_error message=Bad request + * GATE 2 confirmReturn as wrong user: THREW Error + * ``` + * + * A GENERIC 400 CARRYING NOTHING THAT IDENTIFIES A MISMATCH. `coordinator_error` / "Bad request" + * is the same answer a malformed flow id gets, so the `{ mismatch: true }` contract is UNREACHABLE + * THROUGH THE LIVE API TODAY: `normalizeArcadeError` cannot match it without treating EVERY bad + * request as "this handle belongs to somebody else", which is a worse failure than the + * degradation. MEASURED-UNREACHABLE, NOT DEAD: the product keeps the `{ mismatch: true }` branch — + * it costs nothing and starts working the day Arcade distinguishes the case, which is what would + * make it reachable (a distinct error name, code or body field for a user mismatch). + * + * THIS IS A UX DEGRADATION AND NOT A HOLE, which is why the product is not being changed to chase + * it. `confirmReturn` throws, the return-leg route's `catch` turns that into `vendor-unavailable` + * rather than `not-your-authorization` — a vaguer sentence for the person, and nothing written + * either way — and the leg stays bound by D24's `auth_id` comparison, which is an exact equality + * over one id space and is unaffected by any of this. + * + * SO THIS CASE ASSERTS THE MEASUREMENT: the vendor refuses, and it refuses in a way that makes + * `confirmReturn` throw. If it ever stops throwing, THAT is the news — either Arcade now names the + * mismatch (make the branch reachable and assert it) or Arcade accepts any user for a flow, in + * which case D24's `auth_id` comparison is the ONLY thing binding the callback to its attempt. + * Both outcomes are printed below rather than hidden behind the assertion. * - * Confirms with a user id that is NOT the attempt's, first raw (printing status, name, message - * from the error body) and then through `confirmReturn`. - * - `confirmReturn` answers `{ mismatch: true }`: the assumed shape is right. - * - `confirmReturn` THROWS: the shape is wrong (see the printed name). UX degradation, not a - * security hole: D24's `auth_id` comparison still refuses the wrong person. Fix - * `normalizeArcadeError`'s match, not the comparison. - * - Neither refuses: Arcade accepts any user for a flow; D24's `auth_id` comparison is then the - * ONLY thing binding the callback to its attempt. Say so at the gate. * Run BEFORE the correct confirm; if a mismatch burns the flow id the next case says so. */ - test("GATE 2: the user_mismatch wire shape", async () => { - const flowId = need(run.flowId, "no flow_id was captured"); - const client = arcade(); - let raw = "ACCEPTED (no refusal)"; - try { - await client.auth.confirmUser({ flow_id: flowId, user_id: OTHER_USER }); - } catch (error) { - const failure = normalizeArcadeError(error); - raw = `status=${failure?.status} name=${failure?.name} message=${failure?.message}`; - } - const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); - const flow = need( - broker.authorizations, - "the adapter has no authorizations", - ); - let through = ""; - try { - const answer = await flow.confirmReturn({ - handle: flowId, - vendorUserId: OTHER_USER, - }); - through = - "authorizationId" in answer - ? `ACCEPTED, authorization ${answer.authorizationId}` - : "{ mismatch: true }"; - } catch (error) { - through = `THREW ${error instanceof Error ? error.name : typeof error}`; - } - say(`GATE 2 raw confirm_user as wrong user: ${raw}`); - say(`GATE 2 confirmReturn as wrong user: ${through}`); - expect(through).toBe("{ mismatch: true }"); - }); + test( + "GATE 2: the user_mismatch wire shape", + async () => { + const flowId = need(run.flowId, "no flow_id was captured"); + const client = arcade(); + let raw = "ACCEPTED (no refusal)"; + try { + await client.auth.confirmUser({ flow_id: flowId, user_id: OTHER_USER }); + } catch (error) { + const failure = normalizeArcadeError(error); + raw = `status=${failure?.status} name=${failure?.name} message=${failure?.message}`; + } + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + const flow = need( + broker.authorizations, + "the adapter has no authorizations", + ); + let through = ""; + try { + const answer = await flow.confirmReturn({ + handle: flowId, + vendorUserId: OTHER_USER, + }); + through = + "authorizationId" in answer + ? `ACCEPTED, authorization ${answer.authorizationId}` + : "{ mismatch: true }"; + } catch (error) { + through = `THREW ${error instanceof Error ? error.name : typeof error}`; + } + say(`GATE 2 raw confirm_user as wrong user: ${raw}`); + say(`GATE 2 confirmReturn as wrong user: ${through}`); + say( + `GATE 2 ${ + through.startsWith("THREW") + ? "the mismatch is NOT distinguishable on the wire: the product refuses the return leg with vendor-unavailable, and the auth_id comparison does the binding" + : through === "{ mismatch: true }" + ? "the mismatch IS distinguishable now: the { mismatch: true } branch is reachable, so assert it here instead" + : "ARCADE ACCEPTED A WRONG USER FOR THIS FLOW: the auth_id comparison is the ONLY thing binding the callback to its attempt" + }`, + ); + // The vendor must refuse SOMEHOW; an acceptance is the one outcome that is news about the + // binding rather than about the error shape. + expect(raw).not.toBe("ACCEPTED (no refusal)"); + // And it refuses indistinguishably, so the adapter throws. See the docblock: measured, not assumed. + expect(through.startsWith("THREW")).toBe(true); + }, + VENDOR_TIMEOUT_MS, + ); - test("confirm_user returns the mint's own ar_ id as auth_id", async () => { - const flowId = need( - run.flowId, - "no flow_id was captured (if GATE 2 ran, a mismatch may have consumed it)", - ); - const answer = await arcade().auth.confirmUser({ - flow_id: flowId, - user_id: USER, - }); - expect(answer.auth_id).toBe(need(run.authorizationId, "no mint id")); - expect(answer.auth_id).toMatch(/^ar_/); - }); + test( + "confirm_user returns the mint's own ar_ id as auth_id", + async () => { + const flowId = need( + run.flowId, + "no flow_id was captured (if GATE 2 ran, a mismatch may have consumed it)", + ); + const answer = await arcade().auth.confirmUser({ + flow_id: flowId, + user_id: USER, + }); + expect(answer.auth_id).toBe(need(run.authorizationId, "no mint id")); + expect(answer.auth_id).toMatch(/^ar_/); + }, + VENDOR_TIMEOUT_MS, + ); - test("auth.status reaches completed and names the attempt's user_id", async () => { - const id = need(run.authorizationId, "no mint id"); - const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); - const flow = need( - broker.authorizations, - "the adapter has no authorizations", - ); - let last = ""; - // Only the three narrowed fields are ever held; the token-carrying response never leaves the adapter. - for (let i = 0; i < 6; i += 1) { - const seen = await flow.status(id); - last = seen.status; - if (seen.status === "completed") { - expect(seen.userId).toBe(USER); - run.grantedScopes = seen.scopes; - say( - `AUTH STATUS completed; granted scopes: ${seen.scopes.join(" ") || "(none listed)"}`, - ); - return; + test( + "auth.status reaches completed and names the attempt's user_id", + async () => { + const id = need(run.authorizationId, "no mint id"); + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + const flow = need( + broker.authorizations, + "the adapter has no authorizations", + ); + let last = ""; + // Only the three narrowed fields are ever held; the token-carrying response never leaves the adapter. + for (let i = 0; i < STATUS_POLLS; i += 1) { + const seen = await flow.status(id); + last = seen.status; + if (seen.status === "completed") { + expect(seen.userId).toBe(USER); + run.grantedScopes = seen.scopes; + say( + `AUTH STATUS completed; granted scopes: ${seen.scopes.join(" ") || "(none listed)"}`, + ); + return; + } + await new Promise((r) => setTimeout(r, STATUS_POLL_INTERVAL_MS)); } - await new Promise((r) => setTimeout(r, 2000)); - } - throw new Error( - `auth.status never reached completed; last status was ${last}.`, - ); - }); + throw new Error( + `auth.status never reached completed; last status was ${last}.`, + ); + }, + STATUS_TIMEOUT_MS, + ); /** * THE BLAST-RADIUS MEASUREMENT. `met` across the WHOLE toolkit, from the clean baseline taken above. @@ -444,14 +531,18 @@ describe.skipIf(!live)("Arcade, for real", () => { * granted. One read-only consent that flips every action to `met: true` means a single approval * granted the whole toolkit (Github measured 43 of 43). Prints `met before -> after of total`. */ - test("blast radius: met across the whole toolkit, before and after one consent", async () => { - const items = await listToolkit(arcade(), TOOLKIT, USER); - const after = metCount(items); - say( - `BLAST RADIUS ${TOOLKIT}: met ${run.baselineMet} -> ${after} of ${items.length} after consenting to ONE action (${run.action})`, - ); - expect(after).toBeGreaterThan(need(run.baselineMet ?? 0, "no baseline")); - }); + test( + "blast radius: met across the whole toolkit, before and after one consent", + async () => { + const items = await listToolkit(arcade(), TOOLKIT, USER); + const after = metCount(items); + say( + `BLAST RADIUS ${TOOLKIT}: met ${run.baselineMet} -> ${after} of ${items.length} after consenting to ONE action (${run.action})`, + ); + expect(after).toBeGreaterThan(need(run.baselineMet ?? 0, "no baseline")); + }, + VENDOR_TIMEOUT_MS, + ); /** * GATE 3 — PER-ACTION ISOLATION THROUGH OUR VERIFIER, on a scoped provider (the spec's "Unproven"). @@ -465,122 +556,202 @@ describe.skipIf(!live)("Arcade, for real", () => { * On a toolkit whose actions carry no scopes (Github), isolation cannot be measured and this says * so, which is a result: use ARCADE_LIVE_TOOLKIT=Gmail for a scoped provider. */ - test("GATE 3: per-action isolation through our verifier", async () => { - const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); - const grants = await broker.grantsFor({ - account: ACCOUNT, - toolkit: TOOLKIT, - }); - const granted = new Set(run.grantedScopes ?? []); - const scoped = grants.filter( - (g) => g.state === "authorized" || g.state === "needs-consent", - ); - const violations = scoped.filter( - (g) => - g.state === "authorized" && - g.scopes.length > 0 && - g.scopes.some((scope) => !granted.has(scope)), - ); - const withScopes = scoped.filter((g) => g.scopes.length > 0).length; - say( - `GATE 3 ${TOOLKIT}: ${grants.filter((g) => g.state === "authorized").length} of ${grants.length} actions authorized; ${withScopes} carry scopes; ` + - `${violations.length} authorized beyond the granted scopes` + - (withScopes === 0 - ? " -> NOT MEASURABLE on this toolkit (no action carries scopes); rerun on a scoped one" - : violations.length === 0 - ? " -> isolation HOLDS" - : ` -> isolation FAILS: ${violations.map((v) => v.action).join(", ")}`), - ); - expect(violations).toEqual([]); - }); + test( + "GATE 3: per-action isolation through our verifier", + async () => { + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + const grants = await broker.grantsFor({ + account: ACCOUNT, + toolkit: TOOLKIT, + }); + const granted = new Set(run.grantedScopes ?? []); + const scoped = grants.filter( + (g) => g.state === "authorized" || g.state === "needs-consent", + ); + const violations = scoped.filter( + (g) => + g.state === "authorized" && + g.scopes.length > 0 && + g.scopes.some((scope) => !granted.has(scope)), + ); + const withScopes = scoped.filter((g) => g.scopes.length > 0).length; + say( + `GATE 3 ${TOOLKIT}: ${grants.filter((g) => g.state === "authorized").length} of ${grants.length} actions authorized; ${withScopes} carry scopes; ` + + `${violations.length} authorized beyond the granted scopes` + + (withScopes === 0 + ? " -> NOT MEASURABLE on this toolkit (no action carries scopes); rerun on a scoped one" + : violations.length === 0 + ? " -> isolation HOLDS" + : ` -> isolation FAILS: ${violations.map((v) => v.action).join(", ")}`), + ); + expect(violations).toEqual([]); + }, + VENDOR_TIMEOUT_MS, + ); /** - * GATE 4 — THE SERVER-SIDE `provider_id` LIST FILTER'S CASE HANDLING (6.2 matches client-side - * because this is unverified). Lists this user's connections filtered by the provider id exactly, - * upper-cased and lower-cased, and prints the row counts. + * GATE 4 — `provider_id` MEANS TWO DIFFERENT THINGS ON TWO OBJECTS, and the list filter takes the + * second one. THIS CASE USED TO ASSERT THE WRONG THING, AND THAT IS WHY A PRODUCT DEFECT HID. + * + * It filtered this user's connections by the TOOLKIT'S PROVIDER TYPE, found nothing, and printed + * `provider "google": exact 0, upper 0, lower 0 -> filter does not take this provider id`. That + * conclusion was wrong. The filter takes a provider id perfectly well; the type is simply not the + * id a connection is filed under. Direct probing of the live API measured the two meanings: + * + * - on a TOOL LISTING, `requirements.authorization.provider_id` is the provider TYPE: `"google"`; + * - on a USER CONNECTION ROW, `provider_id` is the provider INSTANCE id — an operator's + * configuration of a type: `"google-openbot"`, `"cpk-poc"`, `"arcade-google"`; + * - on an AUTH-PROVIDER CONFIG (`admin.authProviders.list()`), `id` is the INSTANCE and + * `provider_id` is the TYPE, which is the only bridge between the two. Verbatim: + * + * ``` + * {"id":"cpk-poc", "provider_id":"github", "type":"oauth2", "status":"active"} + * {"id":"google-openbot", "provider_id":"google", "type":"oauth2", "status":"active"} + * {"id":"arcade-google", "provider_id":"google", "type":"oauth2", "status":"active"} + * ``` + * + * A connection row carries NO vendor-type field to compare against instead. Its full key set, + * verbatim: `id`, `provider_type`, `connection_id`, `connection_status`, `scopes`, `user_id`, + * `provider_id`, `provider_description`, `provider_user_info` — and `provider_type` is `"oauth2"`, + * the auth SCHEME, not the vendor. The instance id is the only link there is. + * + * SO THE FIRST THING THIS PRINTS IS THE CONSENTED CONNECTION'S OWN `provider_id`, VERBATIM. It is + * the single most valuable line in the run and its absence is what made today's result ambiguous + * for an hour: `exact 0` reads as a broken filter until you can see what the row actually says. + * + * Then: resolve the toolkit's type to its configured INSTANCE ids, assert the connection is found + * by instance, and measure the filter's case handling against the instance — the value a filter + * would really be given — rather than against a value nothing is filed under. * - exact finds the row, the others do not: the server filter is CASE-SENSITIVE; the client-side * fold is required and must stay. * - all three find it: case-INSENSITIVE; the fold is redundant but harmless. - * - exact finds nothing: the filter does not take this provider id at all; do not trust it. - * Also checks, through the adapter, that the connection is seen and named under the toolkit's - * provider. + * - exact finds nothing even by instance: THEN the filter is not to be trusted, and that is a + * finding about the filter rather than about the spelling. */ - test("GATE 4: provider_id filter case handling, and the adapter sees the connection", async () => { - const client = arcade(); - const items = await listToolkit(client, TOOLKIT); - const provider = need( - providersOf(items)[0], - `${TOOLKIT} names no provider_id`, - ); - const count = async (provider_id: string) => { - let n = 0; + test( + "GATE 4: provider_id is a TYPE on a listing and an INSTANCE on a connection", + async () => { + const client = arcade(); + const items = await listToolkit(client, TOOLKIT); + const providerType = need( + providersOf(items)[0], + `${TOOLKIT} names no provider_id`, + ); + + const rows = []; for await (const row of client.admin.userConnections.list({ user_id: USER, - provider_id, })) { - void row; - n += 1; + rows.push(row); } - return n; - }; - const exact = await count(provider); - const upper = await count(provider.toUpperCase()); - const lower = await count(provider.toLowerCase()); - say( - `GATE 4 provider "${provider}": exact ${exact}, upper ${upper}, lower ${lower} -> ` + - (exact === 0 - ? "filter does not take this provider id" - : upper === exact && lower === exact - ? "server filter is CASE-INSENSITIVE" - : "server filter is CASE-SENSITIVE (keep the client-side fold)"), - ); - expect(exact).toBeGreaterThan(0); + const row = need(rows[0], "the throwaway user holds no connection"); + // VERBATIM, and nothing else off the row: this is the measurement. + say( + `GATE 4 connection provider_id VERBATIM: ${JSON.stringify(row.provider_id)}` + + ` (provider_type ${JSON.stringify(row.provider_type)} — the auth scheme, not the vendor);` + + ` ${TOOLKIT} tool listing names provider_id ${JSON.stringify(providerType)}`, + ); - const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); - expect(await broker.authProviderOf?.(TOOLKIT)).toBe(provider); - expect( - await broker.isConnected({ account: ACCOUNT, toolkit: TOOLKIT }), - ).toBe(true); - }); + const configs = (await client.admin.authProviders.list()).items; + if (!Array.isArray(configs)) { + throw new Error( + "admin.authProviders.list() answered without an `items` array, so the provider INSTANCE ids of the toolkit's type could not be resolved.", + ); + } + const instances = configs + .filter( + (config) => + config.provider_id?.toLowerCase() === providerType.toLowerCase(), + ) + .map((config) => config.id) + .filter((id): id is string => typeof id === "string" && id !== ""); + say( + `GATE 4 instances configured for type "${providerType}": ${instances.join(", ") || "(none)"}`, + ); + const instance = need( + instances.find( + (id) => id.toLowerCase() === row.provider_id?.toLowerCase(), + ), + `the connection's provider_id ${JSON.stringify(row.provider_id)} matches no configured instance of type ${providerType}`, + ); + + const count = async (provider_id: string) => { + let n = 0; + for await (const found of client.admin.userConnections.list({ + user_id: USER, + provider_id, + })) { + void found; + n += 1; + } + return n; + }; + const exact = await count(instance); + const upper = await count(instance.toUpperCase()); + const lower = await count(instance.toLowerCase()); + say( + `GATE 4 instance "${instance}" (of type "${providerType}"): exact ${exact}, upper ${upper}, lower ${lower} -> ` + + (exact === 0 + ? "the filter does not even take the INSTANCE id; do not trust it" + : upper === exact && lower === exact + ? "server filter is CASE-INSENSITIVE" + : "server filter is CASE-SENSITIVE (keep the client-side fold)"), + ); + expect(exact).toBeGreaterThan(0); + + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + // The adapter's `authProviderOf` reads the TOOL LISTING, so its answer is the TYPE, and the + // adapter expands that into the instance set before it touches a connection row. + expect(await broker.authProviderOf?.(TOOLKIT)).toBe(providerType); + expect( + await broker.isConnected({ account: ACCOUNT, toolkit: TOOLKIT }), + ).toBe(true); + }, + VENDOR_TIMEOUT_MS, + ); /** * FENCED FACT 2: delete takes `connection_id` (`ac_...`); the `id` (`at_...`) listed first answers * 500. Run against this run's one throwaway connection, then it is really deleted. */ - test("delete takes connection_id, and the at_ id answers 500", async () => { - const client = arcade(); - const rows = []; - for await (const row of client.admin.userConnections.list({ - user_id: USER, - })) { - rows.push(row); - } - const row = need(rows[0], "the throwaway user holds no connection"); - run.connectionId = need( - row.connection_id, - "the row carries no connection_id", - ); - run.rowId = need(row.id, "the row carries no id"); - expect(run.connectionId).toMatch(/^ac_/); - expect(run.rowId).toMatch(/^at_/); + test( + "delete takes connection_id, and the at_ id answers 500", + async () => { + const client = arcade(); + const rows = []; + for await (const row of client.admin.userConnections.list({ + user_id: USER, + })) { + rows.push(row); + } + const row = need(rows[0], "the throwaway user holds no connection"); + run.connectionId = need( + row.connection_id, + "the row carries no connection_id", + ); + run.rowId = need(row.id, "the row carries no id"); + expect(run.connectionId).toMatch(/^ac_/); + expect(run.rowId).toMatch(/^at_/); - const refusal = await failureOf(() => - client.admin.userConnections.delete(need(run.rowId, "no id")), - ); - expect(refusal.status).toBe(500); + const refusal = await failureOf(() => + client.admin.userConnections.delete(need(run.rowId, "no id")), + ); + expect(refusal.status).toBe(500); - await client.admin.userConnections.delete(run.connectionId); - const left = []; - for await (const remaining of client.admin.userConnections.list({ - user_id: USER, - })) { - left.push(remaining); - } - expect(left).toEqual([]); + await client.admin.userConnections.delete(run.connectionId); + const left = []; + for await (const remaining of client.admin.userConnections.list({ + user_id: USER, + })) { + left.push(remaining); + } + expect(left).toEqual([]); - const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); - expect( - await broker.isConnected({ account: ACCOUNT, toolkit: TOOLKIT }), - ).toBe(false); - }); + const { broker } = createArcadeClient(need(key, "no ARCADE_API_KEY")); + expect( + await broker.isConnected({ account: ACCOUNT, toolkit: TOOLKIT }), + ).toBe(false); + }, + VENDOR_TIMEOUT_MS, + ); }); From 75614fb201f2fd4213ee4df407346e43cfbcad46 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 18:49:09 +0200 Subject: [PATCH 113/232] test(memory): name the broker the defaults fixtures already use `main`'s memory-defaults suite built a plugin store without `brokerProvider`. The option is required and deliberately has no fallback, because the store dispatches its toolkit read on it, so all nine of those tests died in `toolkitOfFor` the moment this branch and `main` met. The fixtures are `composio://` rows with `provenance: "composio"`, so they now say so. `server/tests` sits outside `tsc`, which is why a missing required option surfaced as a runtime `TypeError` rather than a type error. This belonged in the merge commit and missed it: the `git add` that should have staged it named a path that does not exist, which makes git stage nothing, and the error was suppressed. The merge commit's verification line describes a tree that had this fix applied but uncommitted. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/memory-defaults.integration.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/tests/memory-defaults.integration.test.ts b/server/tests/memory-defaults.integration.test.ts index 1029f9bf3..ee2e0aa50 100644 --- a/server/tests/memory-defaults.integration.test.ts +++ b/server/tests/memory-defaults.integration.test.ts @@ -38,6 +38,9 @@ const plugins = createPluginStore({ auditStore: createAuditStore(database), credentials: createCredentialStore(database), encryptionKey: Buffer.alloc(32, 7).toString("base64"), + // This suite's apps are `composio://` rows with `provenance: "composio"`, and the store has no + // default for this: it dispatches the toolkit read on it, so the fixtures name their own broker. + brokerProvider: "composio", policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), callVendor: async () => ({ text: "[]", isError: false }), }); From 5b2ed4cb32cb7dcba0f783fc063bfeeb9b3df36b Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 19:44:19 +0200 Subject: [PATCH 114/232] test(arcade): give back the pools these files open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun test` runs every file in one process, a pool is never handed back, and nothing reaps an idle connection — so a file costs two connections from its first query until the run ends. PostgreSQL's 100, less the superuser reserve, is therefore reached by WRITING A FILE rather than by any file misbehaving, and these fifteen are the files that reached it. Measured, because the prose this branch shipped says otherwise. The sharded runner's docblock states the slots go "during the import phase"; constructing a pool opens nothing at all, the first query opens the pool's full `max`, and twenty pools sat flat at forty connections for as long as they were watched. Exhaustion is a function of how many files have RUN, which is why a sharded sweep never sees it and `test:ci` does. Half this suite already knew. Forty-three files on `main` close their pool in their own `afterAll`; fifty-five do not, and the fifteen added here were fifteen more. Closing them puts this branch back at `main`'s own number, which is the number CI passes at today. Nothing about the harness changes: no workflow edit, no shared pool, no new convention — these files now do what `identity-store.integration.test.ts` and forty-two others already do. The close goes last in each file's `afterAll`, after that file's cleanup, because hooks run first-registered-first and a pool closed before the deletes takes the deletes with it. Verified against a freshly migrated test database: `format:check` 0, `lint` 0, `typecheck` 0, `bun test app/tests` 1246 pass / 0 fail, `bun run test:server:sharded` 5027 pass / 1 fail / 19 skip, and `bun run test:ci` — the whole-repo single-process run CI performs — peaked at 84 connections against a ceiling of 97, with zero `too many clients`, where it had reported 114 such failures before. The one remaining failure is not this change: `arcade-journey`'s "two separately-shared apps" fails identically with and without it, and is the merge-gate item of that name. Connection exhaustion had been hiding it. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-attempt-schema.test.ts | 1 + .../arcade-attempt-store.integration.test.ts | 1 + server/tests/arcade-authorize-cancel.test.ts | 1 + server/tests/arcade-authorize-route.test.ts | 1 + ...de-completion-transaction.integration.test.ts | 1 + server/tests/arcade-disconnect-cascade.test.ts | 9 ++++++++- server/tests/arcade-disconnect-route.test.ts | 1 + server/tests/arcade-grants-route.test.ts | 1 + server/tests/arcade-journey.integration.test.ts | 1 + server/tests/arcade-provider-check.test.ts | 1 + server/tests/arcade-sibling-collateral.test.ts | 9 ++++++++- server/tests/arcade-siblings.test.ts | 9 ++++++++- server/tests/arcade-verify.test.ts | 1 + server/tests/gate-grant-is-local.test.ts | 16 +++++++++++++++- server/tests/shared-use-arcade-urls.test.ts | 6 ++++++ 15 files changed, 55 insertions(+), 4 deletions(-) diff --git a/server/tests/arcade-attempt-schema.test.ts b/server/tests/arcade-attempt-schema.test.ts index 945a85ada..d950fa1dd 100644 --- a/server/tests/arcade-attempt-schema.test.ts +++ b/server/tests/arcade-attempt-schema.test.ts @@ -42,6 +42,7 @@ beforeAll(async () => { afterAll(async () => { await database.execute(sql`delete from mcp_servers where id = ${serverId}`); + await database.$client.close(); }); describe("pending_authorizations", () => { diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 160db034b..f6a0ce1e4 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -97,6 +97,7 @@ beforeAll(async () => { afterAll(async () => { // Cascades to the attempts. await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + await database.$client.close(); }); describe("claimAuthorizationAttempt", () => { diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 8d8fd926b..5fae74c55 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -180,6 +180,7 @@ afterAll(async () => { .delete(pendingAuthorizations) .where(inArray(pendingAuthorizations.actorId, actors)); } + await database.$client.close(); }); beforeEach(() => { diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index 6d8aaea4f..7296ab394 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -187,6 +187,7 @@ afterAll(async () => { .delete(pendingAuthorizations) .where(inArray(pendingAuthorizations.actorId, actors)); } + await database.$client.close(); }); beforeEach(() => { diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index fe43db73c..cea1d5d30 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -187,6 +187,7 @@ afterAll(async () => { await database .delete(brokeredConnections) .where(like(brokeredConnections.app, `%-${suite}`)); + await database.$client.close(); }); describe("completeAuthorizationAttempt", () => { diff --git a/server/tests/arcade-disconnect-cascade.test.ts b/server/tests/arcade-disconnect-cascade.test.ts index f3491f229..785f2b253 100644 --- a/server/tests/arcade-disconnect-cascade.test.ts +++ b/server/tests/arcade-disconnect-cascade.test.ts @@ -1,5 +1,5 @@ // server/tests/arcade-disconnect-cascade.test.ts -import { afterEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; @@ -10,6 +10,13 @@ import { createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +/* The pool this file opened, given back. `bun test` runs every file in one process and nothing + * reaps an idle connection, so a pool held to the end of the run is two connections the rest of + * the suite cannot have. */ +afterAll(async () => { + await database.$client.close(); +}); const suite = randomUUID().slice(0, 8); const gmail = `Gmail-${suite}`; const calendar = `GoogleCalendar-${suite}`; diff --git a/server/tests/arcade-disconnect-route.test.ts b/server/tests/arcade-disconnect-route.test.ts index 03f0cd6f4..91eca9bfd 100644 --- a/server/tests/arcade-disconnect-route.test.ts +++ b/server/tests/arcade-disconnect-route.test.ts @@ -186,6 +186,7 @@ afterAll(async () => { toolkits.map((t) => `srv-${t}`), ), ); + await database.$client.close(); }); describe("the sibling list the confirmation shows", () => { diff --git a/server/tests/arcade-grants-route.test.ts b/server/tests/arcade-grants-route.test.ts index a6dd549b2..fa72b67fd 100644 --- a/server/tests/arcade-grants-route.test.ts +++ b/server/tests/arcade-grants-route.test.ts @@ -180,6 +180,7 @@ const grants = ( afterAll(async () => { await database.delete(mcpServers).where(inArray(mcpServers.id, ids)); + await database.$client.close(); }); describe("whose identity is asked about", () => { diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index 71a7f2fc0..d515c283a 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -590,6 +590,7 @@ afterAll(async () => { .delete(mcpServers) .where(inArray(mcpServers.id, [MAIL_ROW, DIARY_ROW])); await database.delete(agents).where(eq(agents.id, BOT)); + await database.$client.close(); }); describe("an Arcade deployment, from the picker to the disconnect", () => { diff --git a/server/tests/arcade-provider-check.test.ts b/server/tests/arcade-provider-check.test.ts index acc630492..d43219cf0 100644 --- a/server/tests/arcade-provider-check.test.ts +++ b/server/tests/arcade-provider-check.test.ts @@ -23,6 +23,7 @@ afterAll(async () => { await database.execute( sql`delete from brokered_connections where app = ${app}`, ); + await database.$client.close(); }); describe("brokered_connections.provider", () => { diff --git a/server/tests/arcade-sibling-collateral.test.ts b/server/tests/arcade-sibling-collateral.test.ts index 99c49137f..732ed2db7 100644 --- a/server/tests/arcade-sibling-collateral.test.ts +++ b/server/tests/arcade-sibling-collateral.test.ts @@ -1,5 +1,5 @@ // server/tests/arcade-sibling-collateral.test.ts -import { afterEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; @@ -18,6 +18,13 @@ import { TEST_POOL, testDatabaseUrl } from "./support/database"; * only by those. A "did not happen" assertion is therefore one only this test could have falsified. */ const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +/* The pool this file opened, given back. `bun test` runs every file in one process and nothing + * reaps an idle connection, so a pool held to the end of the run is two connections the rest of + * the suite cannot have. */ +afterAll(async () => { + await database.$client.close(); +}); const suite = randomUUID().slice(0, 8); const providerOf = (toolkit: string) => diff --git a/server/tests/arcade-siblings.test.ts b/server/tests/arcade-siblings.test.ts index 208994c7a..9d2dde38e 100644 --- a/server/tests/arcade-siblings.test.ts +++ b/server/tests/arcade-siblings.test.ts @@ -1,5 +1,5 @@ // server/tests/arcade-siblings.test.ts -import { afterEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { inArray } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; @@ -12,6 +12,13 @@ import { connectionSiblings, createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +/* The pool this file opened, given back. `bun test` runs every file in one process and nothing + * reaps an idle connection, so a pool held to the end of the run is two connections the rest of + * the suite cannot have. */ +afterAll(async () => { + await database.$client.close(); +}); const suite = randomUUID().slice(0, 8); const gmail = `Gmail-${suite}`; const calendar = `GoogleCalendar-${suite}`; diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index c9230746d..fcf2f3643 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -331,6 +331,7 @@ afterAll(async () => { .delete(brokeredConnections) .where(like(brokeredConnections.app, `Gmail-${suite}-%`)); await database.delete(mcpServers).where(eq(mcpServers.id, APP)); + await database.$client.close(); }); /** diff --git a/server/tests/gate-grant-is-local.test.ts b/server/tests/gate-grant-is-local.test.ts index b5e8bb92e..1ff4042c9 100644 --- a/server/tests/gate-grant-is-local.test.ts +++ b/server/tests/gate-grant-is-local.test.ts @@ -1,5 +1,12 @@ // server/tests/gate-grant-is-local.test.ts -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, +} from "bun:test"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; @@ -16,6 +23,13 @@ import { createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +/* The pool this file opened, given back. `bun test` runs every file in one process and nothing + * reaps an idle connection, so a pool held to the end of the run is two connections the rest of + * the suite cannot have. */ +afterAll(async () => { + await database.$client.close(); +}); const suite = randomUUID().slice(0, 8); const shared = `team-gh-${suite}`; const personal = `my-gh-${suite}`; diff --git a/server/tests/shared-use-arcade-urls.test.ts b/server/tests/shared-use-arcade-urls.test.ts index c33c56ce4..cc5946a57 100644 --- a/server/tests/shared-use-arcade-urls.test.ts +++ b/server/tests/shared-use-arcade-urls.test.ts @@ -78,6 +78,12 @@ async function seed(provider: BrokerProvider) { beforeEach(cleanup); afterAll(cleanup); +/* The pool this file opened, given back. `bun test` runs every file in one process and nothing + * reaps an idle connection, so a pool held to the end of the run is two connections the rest of + * the suite cannot have. */ +afterAll(async () => { + await database.$client.close(); +}); for (const provider of ["arcade", "composio"] as const) { describe(`shared use urls under ${provider}`, () => { From 50aae800659658dd2a11b251de56dae641c945ac Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 20:23:12 +0200 Subject: [PATCH 115/232] test(arcade): ask the trail for its order instead of assuming one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eventsOn` read the audit trail with no `ORDER BY` and the assertion below it takes `.at(-1)`, meaning "the disconnect this test just caused". An unordered `SELECT` does not answer that question. PostgreSQL may return such a read in any order, and what it returns in practice is roughly heap order — which agrees with time order right up until it doesn't. It didn't on CI. The last row back was the PERSONAL disconnect from the describe above, so the Shared app's entry was read as a person ending their own account: - owner: "deployment", reason: "admin" + owner: "arcade-journey-…", reason: "self", vendorRevocationRequested: true THE PRODUCT WAS RIGHT THE WHOLE TIME. Both entries are written, correctly and 48ms apart — the personal one at `…42.954`, the deployment one at `…43.002`. Only the reading of them was wrong, and it reported the product for it: an audit misattribution on a Shared account, which is the kind of defect somebody would rightly stop a release over. Reproduced away from the suite, by making heap order disagree with time order — insert the newer row first, then the older. Unordered, `.at(-1)` is the older row; ordered by `createdAt`, it is the newer one. That is the CI failure exactly. `createdAt` is the only key that can carry this, because `id` is a random UUID and sorts arbitrarily. The two disconnects arrive on separate requests and so in separate transactions, which is what makes their `now()` values distinct. This was invisible until today for a reason worth recording: the whole-suite single-process run is where the two describes' rows meet, and that run has been drowning in `too many clients` since before this branch existed. Verified: `format:check` 0, `lint` 0, `typecheck` 0, `bun test app/tests` 1246 pass / 0 fail, `bun run test:server:sharded` **5028 pass / 0 fail / 19 skip**. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/arcade-journey.integration.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index d515c283a..aadebd109 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -525,13 +525,30 @@ async function connectionsOf(app: string) { .where(eq(brokeredConnections.app, app)); } +/** + * The trail for one app and one event type, OLDEST FIRST — and the order is asked for rather than + * inherited. + * + * `.at(-1)` below means "the disconnect this test just caused", which a `SELECT` with no `ORDER BY` + * does not answer: PostgreSQL may return the rows of an unordered read in any order it likes, and + * what it actually returns is roughly heap order. Insertion order and time order agree on a table + * only ever appended to by one test, which is why this read passed locally and failed on CI — there + * the last row back was the PERSONAL disconnect from the describe above, so a Shared app's audit + * entry was read as a person ending their own account, and the assertion reported a defect in the + * product that the product had not committed. + * + * `createdAt` is the only key that can carry this: `id` is a random UUID, so it sorts arbitrarily. + * The disconnects it separates arrive on their own requests and so in their own transactions, which + * is what makes their `now()` values distinct. + */ async function eventsOn(app: string, eventType: string) { return await database .select() .from(auditEvents) .where( and(eq(auditEvents.targetId, app), eq(auditEvents.eventType, eventType)), - ); + ) + .orderBy(auditEvents.createdAt); } async function attemptRow() { From b057bd20f9126c862143a6887f2331e5e10e0728 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:20:07 +0200 Subject: [PATCH 116/232] fix(arcade): read every listing to its end, and tell an absent total from a zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@arcadeai/arcadejs`'s `OffsetPage` constructor does `this.total_count = body.total_count || 0`, so an ABSENT `total_count` is indistinguishable from a reported zero. Two defects fall out of that one coercion, both measured against a stub HTTP engine with the real SDK building real pages: - `connectionsFor` used the SDK's own auto-pagination (`for await (… of client.admin.userConnections.list(…))`), and `nextPageRequestOptions()` returns `null` the moment the total is falsy. With 250 connections served 100 to a page and no `total_count`, ONE request went out: `revoke` deleted 100, ANSWERED `true`, and left 150 connections live at the vendor — a person told the disconnect succeeded while it had not. That is the outcome `revoke`'s own comment forbids. `isConnected` and `accountName` read off page one the same way. - `listApps` and the transport's `listTools` kept the LAST page's `total_count`, which the coercion turned into `0` whenever the short final page omitted the field. An engine truncating at 250 of 300 committed a 250-action listing as complete, with no error. Fixed at the root, not per call site: - `reportedTotalOf` reads the total off the RAW parsed body (`AbstractPage.body`) and falls back to the page property only when there is no body, so "the vendor did not say" can never read as "the vendor said zero". - `highestTotal` keeps the HIGHEST total any page reported, never the last, so a short final page that drops the field cannot erase page one's claim. - `pagedItems` reads one listing to its end: pages until a SHORT page, never touches the SDK's auto-pagination, and the incompleteness throw is the last thing it yields, so a caller folding items as they arrive still cannot see a short listing finish as a whole one. A partial or unverifiable listing now THROWS on every path — neither a clean success nor an empty "nothing there". - `createArcadeClient`'s `actions.listTools` normalizes `total_count` at the seam, OMITTING the field when the vendor omitted it. That is the only place it can be done: `./arcade` must not import this file (cycle), so it cannot reach `reportedTotalOf` itself. Same-pattern audit over both files, every instance fixed: - `listApps` (`arcade-adapter.ts`) — now through `pagedItems`. - `authProviderInstances` — now through `pagedItems`. - `connectionsFor` — was the SDK's auto-pagination; now explicitly paged. - `grantsFor` — was a single `await` with no loop, and answers one `GrantState` PER ACTION, so a first page silently dropped every action past it (`GithubApi` publishes hundreds). Now paged. - `arcade.ts listTools` — highest-total tracking. - `authProviderOf` — LEFT UNPAGED, deliberately, and the docblock now records why: it answers one fact for the WHOLE toolkit, so a further page can only repeat the first, and paging it would be paid on every `isConnected`, `accountName` and `revoke`. Covering test: `server/tests/arcade-pagination-engine.test.ts` serves real JSON over a real socket through `ARCADE_BASE_URL` and lets the SDK build its own `PagePromise`/`OffsetPage`. The existing adapter suite stubs resource PROTOTYPES and hands the adapter a bare async generator, which is exactly what hides this class — a test in that style cannot go red here. Against the unfixed source this file fails 5 of 7 (revoke spent 100 deletes of 250 and returned `true`); against the fixed source it passes 7 of 7. Symbols added, all module-private to `server/src/plugins/arcade-adapter.ts`, exported by nothing, so they have no call sites outside it: `reportedTotalOf`, `highestTotal`, type `Listing`, `pagedItems`, `CONNECTION_PAGE_SIZE`, `CONNECTION_MAX_PAGES`. No symbol was renamed or removed and no exported signature changed. Call sites enumerated for what did change behaviour: - `actions.listTools` return VALUE shape (a plain `{items, total_count?}` rather than the SDK's `OffsetPage`; the declared `ArcadeActions["listTools"]` type is unchanged). One consumer: `server/src/plugins/arcade.ts:179` (`installed.listTools`), installed from `server/src/index.ts:797`. Test doubles implementing `ArcadeActions` already answer plain objects and are unaffected. - `broker.listApps()` — `server/src/plugins/logos.ts:20`, `server/src/plugins/routes.ts:957`, `server/src/plugins/routes.ts:1066`. - `broker.grantsFor(...)` — `server/src/plugins/routes.ts:2183`. - `broker.isConnected(...)` — `server/src/plugins/store.ts:6887`. - `broker.revoke(...)` — `server/src/plugins/store.ts:4076`, `server/src/plugins/store.ts:7969`, `server/src/plugins/store.ts:8198`. - `broker.accountName(...)` — `server/src/plugins/store.ts:5847`. All of these could already receive a throw from this path — `authProviderOf` and `authProviderInstances` throw on a malformed listing today, and `connectionsFor`'s docblock already records that it propagates onto the Disconnect path — so no caller needed new handling. Three fixtures were taught the page shape the adapter now consumes, because they stubbed `userConnections.list` with a bare async generator that has no `items`: `arcade-adapter-connections.test.ts`, `arcade-adapter.test.ts`, `arcade-journey.integration.test.ts`. The suite's structural blindness in general is not addressed here; it is somebody else's finding. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 324 ++++++++++++------ server/src/plugins/arcade.ts | 12 +- .../tests/arcade-adapter-connections.test.ts | 23 +- server/tests/arcade-adapter.test.ts | 7 +- .../tests/arcade-journey.integration.test.ts | 10 +- server/tests/arcade-pagination-engine.test.ts | 256 ++++++++++++++ 6 files changed, 510 insertions(+), 122 deletions(-) create mode 100644 server/tests/arcade-pagination-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 702596a93..b966b628d 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -60,6 +60,88 @@ const CATALOGUE_PAGE_SIZE = 100; /** A runaway guard, not a bound. */ const CATALOGUE_MAX_PAGES = 1000; +/** + * The `total_count` ARCADE ITSELF SENT, read off the RAW BODY and never off the SDK's page object. + * + * `OffsetPage`'s constructor does `this.total_count = body.total_count || 0`, so a listing that + * never sent the field presents a numeric `0` — and an ABSENT total becomes INDISTINGUISHABLE FROM + * A REPORTED ZERO. Every `typeof reported === "number"` test then passes on a field the vendor + * never sent, the cross-check becomes `seen < 0`, and a truncated listing commits as complete. + * `AbstractPage` keeps the parsed body untouched beside it, so the honest value is read from there; + * the page's own property is the fallback for an answer that has no `body` at all, which is what + * `admin.authProviders.list` (a plain `APIPromise`, not a page) and every hand-built stub return. + */ +function reportedTotalOf(answer: unknown): number | undefined { + const page = record(answer); + if (!page) return undefined; + const raw = (record(page.body) ?? page).total_count; + return typeof raw === "number" ? raw : undefined; +} + +/** + * The HIGHEST total any page reported, never the LAST one. + * + * Keeping the last page's value loses the same way `reportedTotalOf` describes, one step later: an + * engine that truncates at 250 of 300 and drops `total_count` on its short page would otherwise + * erase the 300 that page one reported, and the cross-check would have nothing to compare against. + */ +const highestTotal = ( + total: number | undefined, + reported: number | undefined, +): number | undefined => + reported === undefined + ? total + : total === undefined + ? reported + : Math.max(total, reported); + +/** One listing's page size, runaway guard, page call and the three sentences a failure carries. */ +type Listing = { + pageSize: number; + maxPages: number; + page: (offset: number) => Promise; + malformed: string; + unending: string; + incomplete: (seen: number, total: number) => string; +}; + +/** + * ONE LISTING READ TO ITS END, as a stream of items. + * + * PAGINATION ENDS ON A SHORT PAGE, never on `total_count`, which is optional on the wire; the + * reported total is a CROSS-CHECK and is taken through {@link reportedTotalOf} and + * {@link highestTotal} so that "the vendor did not say" can never read as "the vendor said zero". + * + * THE SDK'S OWN AUTO-PAGINATION IS NOT USED by anything that goes through here, and that is the + * point: `OffsetPage.nextPageRequestOptions` returns `null` the moment `total_count` is falsy, so + * `for await (… of client.x.list(…))` silently stops after page one against any engine that omits + * the field — and the caller cannot tell a complete answer from a first page of it. + * + * THE INCOMPLETENESS THROW IS THE LAST THING THIS YIELDS, after every item, so a caller that folds + * items as they arrive still never sees a short listing finish as a whole one. + */ +async function* pagedItems(listing: Listing): AsyncGenerator { + let seen = 0; + let total: number | undefined; + let ended = false; + for (let index = 0; index < listing.maxPages; index += 1) { + const answer = await listing.page(seen); + const items = record(answer)?.items; + if (!Array.isArray(items)) throw new Error(listing.malformed); + yield* items; + seen += items.length; + total = highestTotal(total, reportedTotalOf(answer)); + if (items.length < listing.pageSize) { + ended = true; + break; + } + } + if (!ended) throw new Error(listing.unending); + if (total !== undefined && seen < total) { + throw new Error(listing.incomplete(seen, total)); + } +} + /** The verdict `classify` falls back to when it could not read a behaviour. See {@link isClassified}. */ const CLOSED_VERDICT = { effect: "write", destructive: true } as const; @@ -139,69 +221,46 @@ async function listApps(client: Arcade): Promise { permissions: Set; }; const counts = new Map(); - let seen = 0; - let total: number | undefined; - let ended = false; - for (let page = 0; page < CATALOGUE_MAX_PAGES; page += 1) { - const answer = await client.tools.list({ - limit: CATALOGUE_PAGE_SIZE, - offset: seen, - }); - const items = (answer as { items?: unknown } | null)?.items; - if (!Array.isArray(items)) { - throw new Error( - "Arcade did not answer with a tool list, so its app catalogue could not be read.", - ); - } - for (const item of items) { - const toolkit = ( - item as { toolkit?: { name?: unknown; description?: unknown } } | null - )?.toolkit; - if (!toolkit || typeof toolkit.name !== "string") continue; - const classified = isClassified(record(item)?.metadata); - const scopes = scopesOf(item); - const standing = - counts.get(toolkit.name) ?? - ({ - slug: toolkit.name, - name: toolkit.name, - description: - typeof toolkit.description === "string" ? toolkit.description : "", - logo: null, - categories: [], - actionCount: 0, - classifiedCount: 0, - scopedActionCount: 0, - permissions: new Set(), - connection: { kind: "consent" }, - } satisfies Tally); - standing.actionCount += 1; - if (classified) standing.classifiedCount += 1; - if (scopes.length > 0) { - standing.scopedActionCount += 1; - // SORTED, THEN JOINED. The other order makes one permission into two the first time the - // vendor lists the same scopes in a different sequence. - standing.permissions.add([...scopes].sort().join(" ")); - } - counts.set(toolkit.name, standing); - } - seen += items.length; - const reported = (answer as { total_count?: unknown }).total_count; - if (typeof reported === "number") total = reported; - if (items.length < CATALOGUE_PAGE_SIZE) { - ended = true; - break; - } - } - if (!ended) { - throw new Error( - `Arcade's catalogue never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, - ); - } - if (total !== undefined && seen < total) { - throw new Error( + for await (const item of pagedItems({ + pageSize: CATALOGUE_PAGE_SIZE, + maxPages: CATALOGUE_MAX_PAGES, + page: (offset) => client.tools.list({ limit: CATALOGUE_PAGE_SIZE, offset }), + malformed: + "Arcade did not answer with a tool list, so its app catalogue could not be read.", + unending: `Arcade's catalogue never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, + incomplete: (seen, total) => `Arcade listed ${seen} of ${total} actions, so its app catalogue is incomplete.`, - ); + })) { + const toolkit = ( + item as { toolkit?: { name?: unknown; description?: unknown } } | null + )?.toolkit; + if (!toolkit || typeof toolkit.name !== "string") continue; + const classified = isClassified(record(item)?.metadata); + const scopes = scopesOf(item); + const standing = + counts.get(toolkit.name) ?? + ({ + slug: toolkit.name, + name: toolkit.name, + description: + typeof toolkit.description === "string" ? toolkit.description : "", + logo: null, + categories: [], + actionCount: 0, + classifiedCount: 0, + scopedActionCount: 0, + permissions: new Set(), + connection: { kind: "consent" }, + } satisfies Tally); + standing.actionCount += 1; + if (classified) standing.classifiedCount += 1; + if (scopes.length > 0) { + standing.scopedActionCount += 1; + // SORTED, THEN JOINED. The other order makes one permission into two the first time the + // vendor lists the same scopes in a different sequence. + standing.permissions.add([...scopes].sort().join(" ")); + } + counts.set(toolkit.name, standing); } // `permissionCount` IS READ OFF THE FINISHED SET, never kept per page: a size taken mid-listing // is a count of the sets seen so far, which is a different number on every page but the last. @@ -221,6 +280,11 @@ const AUTH_PROVIDER_PAGE_SIZE = 100; /** A runaway guard, not a bound. */ const AUTH_PROVIDER_MAX_PAGES = 100; +/** Page size for the user-connection listing. A short page ends it, as `listApps`. */ +const CONNECTION_PAGE_SIZE = 100; +/** A runaway guard, not a bound. */ +const CONNECTION_MAX_PAGES = 100; + /** * The INSTANCE ids of every auth provider this project has configured of one TYPE. * @@ -249,43 +313,23 @@ async function authProviderInstances( ): Promise> { const wanted = authProvider.toLowerCase(); const instances = new Set(); - let seen = 0; - let total: number | undefined; - let ended = false; - for (let page = 0; page < AUTH_PROVIDER_MAX_PAGES; page += 1) { - const answer = await client.admin.authProviders.list({ - query: { limit: AUTH_PROVIDER_PAGE_SIZE, offset: seen }, - }); - const items = (answer as { items?: unknown } | null)?.items; - if (!Array.isArray(items)) { - throw new Error( - `Arcade did not answer with an auth-provider list, so the configurations of ${authProvider} its connections are held under could not be read.`, - ); - } - for (const item of items) { - const config = record(item); - const instance = textOf(config?.id); - if (instance && textOf(config?.provider_id)?.toLowerCase() === wanted) { - instances.add(instance.toLowerCase()); - } - } - seen += items.length; - const reported = (answer as { total_count?: unknown }).total_count; - if (typeof reported === "number") total = reported; - if (items.length < AUTH_PROVIDER_PAGE_SIZE) { - ended = true; - break; - } - } - if (!ended) { - throw new Error( - `Arcade's auth-provider list never ended after ${AUTH_PROVIDER_MAX_PAGES} pages, so it is not trusted.`, - ); - } - if (total !== undefined && seen < total) { - throw new Error( + for await (const item of pagedItems({ + pageSize: AUTH_PROVIDER_PAGE_SIZE, + maxPages: AUTH_PROVIDER_MAX_PAGES, + page: (offset) => + client.admin.authProviders.list({ + query: { limit: AUTH_PROVIDER_PAGE_SIZE, offset }, + }), + malformed: `Arcade did not answer with an auth-provider list, so the configurations of ${authProvider} its connections are held under could not be read.`, + unending: `Arcade's auth-provider list never ended after ${AUTH_PROVIDER_MAX_PAGES} pages, so it is not trusted.`, + incomplete: (seen, total) => `Arcade listed ${seen} of ${total} auth providers, so its configuration list is incomplete.`, - ); + })) { + const config = record(item); + const instance = textOf(config?.id); + if (instance && textOf(config?.provider_id)?.toLowerCase() === wanted) { + instances.add(instance.toLowerCase()); + } } return instances; } @@ -325,6 +369,17 @@ async function authProviderInstances( * `provider_id` is in that set (case-insensitively — Amendment 16's client-side fold stands). Both * the provider and the configuration list are read ONCE per call, never per row. * + * PAGED THROUGH {@link pagedItems}, NEVER THROUGH THE SDK'S `for await (… of … .list(…))`. That + * iterator asks `OffsetPage.nextPageRequestOptions`, which returns `null` the moment `total_count` + * is falsy — and the constructor coerces an ABSENT `total_count` to `0`. So against any engine that + * omits the field, the SDK's own auto-pagination stops after page one: measured at 30 connections + * served 10 to a page, ONE request went out, `isConnected` answered `true`, `accountName` read a + * name off page one, and `revoke` DELETED TEN AND ANSWERED `true` while twenty connections stayed + * live at the vendor. That is the outcome `revoke`'s own comment forbids — a partial ask reading as + * a clean success. Reading to a SHORT PAGE instead ends on evidence the engine cannot omit, and an + * unverifiable listing THROWS rather than answering short, so no caller here can mistake a first + * page for the whole of what somebody holds. + * * A ROW UNDER AN INSTANCE THE CONFIG LIST DOES NOT NAME DOES NOT MATCH — a provider configuration * deleted while a connection outlives it. That is fail-closed: the row is left alone rather than * deleted on a guess about which vendor it belongs to. @@ -344,9 +399,21 @@ async function connectionsFor( if (authProvider === null) return []; const instances = await authProviderInstances(client, authProvider); const rows: ConnectionRow[] = []; - for await (const row of client.admin.userConnections.list({ - user_id: vendorUserId, + for await (const item of pagedItems({ + pageSize: CONNECTION_PAGE_SIZE, + maxPages: CONNECTION_MAX_PAGES, + page: (offset) => + client.admin.userConnections.list({ + user_id: vendorUserId, + limit: CONNECTION_PAGE_SIZE, + offset, + }), + malformed: `Arcade did not answer with a connection list, so what this account holds at ${toolkit} could not be read.`, + unending: `Arcade's connection list never ended after ${CONNECTION_MAX_PAGES} pages, so it is not trusted.`, + incomplete: (seen, total) => + `Arcade listed ${seen} of ${total} connections, so what this account holds at ${toolkit} could not be read in full.`, })) { + const row = item as ConnectionRow; const instance = textOf(row.provider_id)?.toLowerCase(); if (instance !== undefined && instances.has(instance)) rows.push(row); } @@ -464,20 +531,36 @@ function grantStateOf(item: unknown): GrantState[] { * and reads no per-person state off the answer, because a catalogue is the same for everybody and a * per-person listing would make it one person's. Here the question IS about a person, so the * account's vendor id goes out with it. + * + * PAGED TO THE END, as `listApps` is and for its reason: this answers ONE `GrantState` PER ACTION, + * so a single first page would quietly drop every action past it — `GithubApi` publishes hundreds — + * and the grants screen would show a partial holding as the whole of one. A short page ends it and + * an unverifiable listing throws; see {@link pagedItems}. */ async function grantsFor( client: Arcade, vendorUserId: string, toolkit: string, ): Promise { - const page = await client.tools.list({ toolkit, user_id: vendorUserId }); - const items = page?.items; - if (!Array.isArray(items)) { - throw new Error( - `Arcade did not answer with an action list for ${toolkit}, so what this account holds there could not be read.`, - ); + const grants: GrantState[] = []; + for await (const item of pagedItems({ + pageSize: CATALOGUE_PAGE_SIZE, + maxPages: CATALOGUE_MAX_PAGES, + page: (offset) => + client.tools.list({ + toolkit, + user_id: vendorUserId, + limit: CATALOGUE_PAGE_SIZE, + offset, + }), + malformed: `Arcade did not answer with an action list for ${toolkit}, so what this account holds there could not be read.`, + unending: `Arcade's action list for ${toolkit} never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, + incomplete: (seen, total) => + `Arcade listed ${seen} of ${total} actions for ${toolkit}, so what this account holds there could not be read in full.`, + })) { + grants.push(...grantStateOf(item)); } - return items.flatMap(grantStateOf); + return grants; } /** @@ -495,6 +578,14 @@ async function grantsFor( * provider — that is what makes it a toolkit. `listApps` pages to the end because it is COUNTING * actions; this is reading a per-toolkit fact off any of them. * + * IT IS THE ONLY LISTING IN THIS FILE LEFT UNPAGED, DELIBERATELY. `grantsFor` and `connectionsFor` + * were paged because each answers ONE THING PER ROW, so a first page of theirs is a partial answer + * wearing a whole one's clothes. This answers one thing for the WHOLE TOOLKIT, so a further page + * can only repeat what the first already said — and the cost of paging it would be paid on every + * `isConnected`, `accountName` and `revoke`. A toolkit whose later pages disagreed with its first + * would be resolved here rather than unanswered; that is the known edge of this choice, and it is + * the vendor contradicting what a toolkit is. + * * AND IT GUARDS RATHER THAN ASSUMES. The DISTINCT non-null provider ids on the page are collected * and anything other than exactly one answers `null`, so a toolkit that disagrees with itself is * UNANSWERED rather than silently resolved to whichever action came first. Case is carried through @@ -540,12 +631,25 @@ export function createArcadeClient(apiKey: string): { } { const client = new Arcade({ apiKey }); const actions: ArcadeActions = { - listTools: async (q) => - await client.tools.list({ + // `total_count` IS NORMALIZED HERE, and this is the only place it can be. The transport + // (`./arcade`) must not import this file — that direction is a cycle — so it cannot reach + // {@link reportedTotalOf}, and what it receives has to already tell "the vendor did not say" + // apart from "the vendor said zero". Handing it the SDK's `OffsetPage` does not: that object's + // `total_count` is `body.total_count || 0`, so `listTools` read a `0` off an answer that + // carried no total and its `raw.length < total` cross-check could never fire. The FIELD IS + // OMITTED when the vendor omitted it, which is what the declared `total_count?: number` means. + listTools: async (q) => { + const answer = await client.tools.list({ toolkit: q.toolkit, limit: q.limit, offset: q.offset, - }), + }); + // Passed through unchecked: a malformed listing is the transport's sentence to write, and + // its own `Array.isArray` guard is what writes it. + const items = record(answer)?.items as unknown[]; + const total = reportedTotalOf(answer); + return total === undefined ? { items } : { items, total_count: total }; + }, execute: async (r) => { try { return await client.tools.execute({ diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index afa4334f6..9f03963de 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -197,7 +197,17 @@ export async function listTools(connection: { ); } raw.push(...items); - if (typeof answer.total_count === "number") total = answer.total_count; + // THE HIGHEST ANY PAGE REPORTED, NEVER THE LAST. An engine that truncates at 250 of 300 drops + // `total_count` from its short final page, and keeping the last value would erase the 300 page + // one reported and commit 250 actions as a complete listing. (`total_count` reaching here is + // already honest about absence: `createArcadeClient` omits the field when the vendor omitted + // it, because the SDK's page object coerces an absent total to `0`.) + if (typeof answer.total_count === "number") { + total = + total === undefined + ? answer.total_count + : Math.max(total, answer.total_count); + } if (items.length < PAGE_SIZE) { exhausted = false; break; diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index 892867c33..b82aa4827 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -128,12 +128,25 @@ function configs(items: unknown[] = CONFIGS) { return asked; } +/** + * A PAGE, NOT A BARE GENERATOR. `connectionsFor` pages the connection listing itself rather than + * leaning on the SDK's auto-pagination, because `OffsetPage` stops after page one whenever the + * engine omits `total_count` — see `arcade-pagination-engine.test.ts`, which proves that against a + * real HTTP engine. So the stub answers what the real `list` resolves to: an object with `items`, + * honouring the `limit`/`offset` the adapter sends. + */ function listing(rows: unknown[]) { - stub("list", () => - (async function* () { - for (const r of rows) yield r; - })(), - ); + stub("list", (query) => { + const asked = query as { limit?: number; offset?: number } | undefined; + const offset = asked?.offset ?? 0; + const limit = asked?.limit ?? rows.length; + return Promise.resolve({ + items: rows.slice(offset, offset + limit), + offset, + limit, + total_count: rows.length, + }); + }); } /** The TYPE each toolkit's listing names. Nothing here is an instance id. */ diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index 325782ca9..8a884962f 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -173,10 +173,11 @@ describe("createArcadeClient", () => { test("accountName reads provider_user_info.name, then .login, else null", async () => { const rows: unknown[] = []; + // A PAGE, as the real `list` resolves to: `connectionsFor` pages the listing itself rather + // than through the SDK, whose auto-pagination stops after page one when the engine omits + // `total_count`. See `arcade-pagination-engine.test.ts`. stub(connectionsProto(), "list", () => - (async function* () { - for (const r of rows) yield r; - })(), + Promise.resolve({ items: rows, offset: 0, total_count: rows.length }), ); // The toolkit's own listing names the provider TYPE its connections are held under. stub(toolsProto(), "list", () => diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index aadebd109..a4a4825c3 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -414,9 +414,13 @@ function installVendor() { (...args: unknown[]) => { const { user_id: userId } = args[0] as { user_id: string }; const rows = vendorConnections.filter((row) => row.user_id === userId); - return (async function* () { - for (const row of rows) yield row; - })(); + // A PAGE, as the real `list` resolves to. `connectionsFor` pages the listing itself: the + // SDK's own auto-pagination stops after page one when the engine omits `total_count`. + return Promise.resolve({ + items: rows, + offset: 0, + total_count: rows.length, + }); }, ); diff --git a/server/tests/arcade-pagination-engine.test.ts b/server/tests/arcade-pagination-engine.test.ts new file mode 100644 index 000000000..6df7bd068 --- /dev/null +++ b/server/tests/arcade-pagination-engine.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { listTools } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** + * THE ADAPTER AGAINST A REAL HTTP ENGINE, BECAUSE NOTHING LESS CAN SEE THIS CLASS OF DEFECT. + * + * The rest of the adapter suite stubs the SDK's resource PROTOTYPES and hands the adapter a bare + * async generator in place of a `PagePromise`. That substitution is exactly what hides the bug this + * file is about: the real `OffsetPage` constructor does `this.total_count = body.total_count || 0` + * and `nextPageRequestOptions()` returns `null` on a falsy total, so an engine that OMITS + * `total_count` ends the SDK's own auto-pagination after page one AND disables every + * `seen < total` cross-check — while a hand-written generator paginates perfectly and reports + * whatever total the fixture chose to carry. + * + * So these tests serve real JSON over a real socket through `ARCADE_BASE_URL` and let the SDK build + * its own pages. `new Arcade({ apiKey })` reads that variable at construction, which is why the + * server is started BEFORE `createArcadeClient`. + */ + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +type EngineOptions = { + /** How many connection rows the engine holds. */ + connections: number; + /** Send `total_count` on the connection listing at all? */ + connectionTotal: boolean; + /** Serve this many connection rows and no more, while still reporting the full `total_count`. */ + connectionTruncateAt?: number; + /** The toolkit listing claims this many actions... */ + toolsClaimed?: number; + /** ...and serves only this many, dropping `total_count` from the short final page. */ + toolsServed?: number; +}; + +type Engine = { + port: number; + /** Every path+query the SDK asked for, in order. */ + requests: string[]; + stop: () => void; +}; + +const connectionRow = (n: number) => ({ + id: `at_${n}`, + connection_id: `ac_${n}`, + connection_status: "active", + // A provider INSTANCE id, never a provider type: see arcade-adapter-connections.test.ts. + provider_id: "google-openbot", + provider_type: "oauth2", + provider_user_info: { name: `person ${n}` }, + scopes: [], + user_id: account.vendorUserId, +}); + +const toolRow = (toolkit: string, n: number) => ({ + qualified_name: `${toolkit}.Action${n}`, + description: "", + toolkit: { name: toolkit, description: "" }, + input: { parameters: [] }, + requirements: { + authorization: { provider_id: "google", oauth2: { scopes: [] } }, + }, +}); + +function startEngine(options: EngineOptions): Engine { + const requests: string[] = []; + const rows = Array.from({ length: options.connections }, (_, n) => + connectionRow(n), + ); + const served = options.connectionTruncateAt ?? rows.length; + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(url.pathname + url.search); + const offset = Number(url.searchParams.get("offset") ?? "0"); + const limit = Number(url.searchParams.get("limit") ?? "100"); + + if (url.pathname.startsWith("/v1/admin/user_connections/")) { + return new Response(null, { status: 204 }); + } + if (url.pathname === "/v1/admin/user_connections") { + const page = rows.slice(offset, Math.min(offset + limit, served)); + const body: Record = { + items: page, + offset, + limit, + page_count: page.length, + }; + if (options.connectionTotal) body.total_count = rows.length; + return Response.json(body); + } + if (url.pathname === "/v1/admin/auth_providers") { + return Response.json({ + items: [ + { id: "google-openbot", provider_id: "google", type: "oauth2" }, + ], + offset: 0, + limit, + page_count: 1, + total_count: 1, + }); + } + if (url.pathname === "/v1/tools") { + const toolkit = url.searchParams.get("toolkit") ?? "Gmail"; + const claimed = options.toolsClaimed ?? 1; + const toolsServed = options.toolsServed ?? claimed; + const page: unknown[] = []; + for (let n = offset; n < Math.min(offset + limit, toolsServed); n++) { + page.push(toolRow(toolkit, n)); + } + const body: Record = { + items: page, + offset, + limit, + page_count: page.length, + }; + // The short final page DROPS the field — which is what defeats a cross-check that keeps + // the LAST page's total rather than the highest one reported. + if (page.length === limit) body.total_count = claimed; + return Response.json(body); + } + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, requests, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(options: EngineOptions): Engine { + running = startEngine(options); + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +const deletes = (e: Engine) => + e.requests.filter((r) => r.startsWith("/v1/admin/user_connections/")); + +describe("the connection listing is read to its end, not to the SDK's", () => { + test("revoke deletes every connection when the engine omits total_count", async () => { + // 250 rows at a page size of 100: three pages, and NO `total_count` anywhere. The SDK's own + // auto-pagination gives up after the first of them. + const e = engine({ connections: 250, connectionTotal: false }); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.revoke({ account, toolkit: "Gmail" })).toBe(true); + expect(deletes(e).length).toBe(250); + }); + + test("a truncated connection listing throws rather than reading as success or as nothing", async () => { + // The engine reports 250 and serves 100: a short second page, which ends the listing, and a + // `total_count` that says it should not have. + const e = engine({ + connections: 250, + connectionTotal: true, + connectionTruncateAt: 100, + }); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + /listed 100 of 250 connections/, + ); + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/listed 100 of 250 connections/); + await expect( + broker.accountName({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/listed 100 of 250 connections/); + // NOT ONE DELETE WAS SPENT on a listing that could not be trusted to be the whole of it. + expect(deletes(e).length).toBe(0); + }); + + test("isConnected and accountName read the whole listing", async () => { + engine({ connections: 250, connectionTotal: false }); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe( + "person 0", + ); + }); +}); + +describe("the action listing's total_count cross-check survives a short final page", () => { + test("the transport refuses a listing truncated at 250 of 300", async () => { + engine({ + connections: 0, + connectionTotal: true, + toolsClaimed: 300, + toolsServed: 250, + }); + const { actions } = createArcadeClient("never-dialled"); + useArcadeClient(actions); + + await expect(listTools({ url: "arcade://Gmail" })).rejects.toThrow( + /listed 250 of 300 actions for Gmail/, + ); + }); + + test("a complete listing is still accepted", async () => { + engine({ + connections: 0, + connectionTotal: true, + toolsClaimed: 250, + toolsServed: 250, + }); + const { actions } = createArcadeClient("never-dialled"); + useArcadeClient(actions); + + expect((await listTools({ url: "arcade://Gmail" })).length).toBe(250); + }); + + test("the catalogue refuses a truncated listing too", async () => { + engine({ + connections: 0, + connectionTotal: true, + toolsClaimed: 300, + toolsServed: 250, + }); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.listApps()).rejects.toThrow( + /listed 250 of 300 actions, so its app catalogue is incomplete/, + ); + }); + + test("grantsFor pages past the first page", async () => { + engine({ + connections: 0, + connectionTotal: true, + toolsClaimed: 250, + toolsServed: 250, + }); + const { broker } = createArcadeClient("never-dialled"); + + expect((await broker.grantsFor({ account, toolkit: "Gmail" })).length).toBe( + 250, + ); + }); +}); From 6d6fd207124cff8c306e08707343cc9ff6f16d40 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:23:22 +0200 Subject: [PATCH 117/232] fix(db): refuse to migrate a database that cannot reach every migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PgDialect.migrate` decides what to run with one comparison — the journal's `when` against the newest `created_at` in `drizzle.__drizzle_migrations` — and says nothing about a migration stamped below that mark which it has never run. Merging `main` into this branch renumbered the arcade work to `0057` and gave main's `0053_identity_links` a `when` of 1791547580808, older than the 1791774497487 a pre-merge database had already recorded for the branch's own `0054_spicy_wallflower`. Reproduced against a throwaway database: the pre-merge folder applies, the post-merge folder dies on `CREATE TABLE "pending_authorizations"`, and after the obvious hand repair (drop that table, run again) the migrator prints `{"type":"migrations-applied","status":"ok"}` while `identity_links` and `identity_link_challenges` were never created. A fresh database is unaffected, which is why CI — always migrating from empty — cannot see it. The migrations themselves are untouched: `0057_arcade_provider.sql` is byte identical to a fresh generate and the snapshot chain is intact, so renumbering again would be a second defect. What changes is that the migrator now looks before it runs. `migrationsOutOfReach` names every migration stamped at or below the database's high-water mark whose hash is not among the rows already applied — those are unreachable, not pending — and `applyMigrations` throws `MigrationsOutOfReachError` with the names and two ways out instead of migrating. Nothing is applied, so the hard failure and the silent skip that follows its hand repair are both replaced by one refusal that exits non-zero. It refuses rather than repairs on purpose: the migrations are out of reach because some other journal already ran here, and nothing in this process knows what that journal did to the schema. An operator recreating a disposable database or restamping a durable one is a decision, not a side effect of a deploy. Validated for false positives against two real databases: `openbot_test` (58 rows, fully migrated with this folder) and the development `openbot` (53 rows, at 0052) both report nothing out of reach, and every applied hash is accounted for by this folder — confirming the sha256-of-the-whole-file recipe matches what drizzle has been writing for the whole history. Every migrator invocation now runs the guarded script, since the unguarded CLI was reachable from the three environments that actually hold such a database: `db:migrate`, the compose `migrate` service that `scripts/start.sh` runs, and CI. `drizzle-kit generate` and `drizzle-kit check` are unchanged. Audit: `server/drizzle` is the only migration folder and `server/drizzle.config.ts` the only config in the repository, and after this change `server/scripts/migrate.ts` is the only thing that applies them. `scripts/start.sh` keeps its two-table canary after a successful migrate, which is the symptom-level version of this check and now sits behind a cause-level one. Call sites of every symbol added or changed: - `readMigrationFolder`, `migrationsOutOfReach`, `outOfReachRefusal`, `FolderMigration`, `AppliedMigration` (new, server/src/db/migration-reach.ts): server/src/db/apply-migrations.ts, server/tests/migration-reach.test.ts. - `applyMigrations`, `MigrationsOutOfReachError` (new, server/src/db/apply-migrations.ts): server/scripts/migrate.ts, server/tests/migration-reach.integration.test.ts. - `migrate`/`drizzle` imports removed from server/scripts/migrate.ts: no other module imported them from there. - `db:migrate` keeps its name and its behaviour on a healthy database, so its three mentions stand: README.md:430, docs/development.md:77, scripts/start.sh:277. - `bunx drizzle-kit migrate` had exactly two invocation sites, both replaced: .github/workflows/ci.yml and docker-compose.yml. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- .github/workflows/ci.yml | 9 +- docker-compose.yml | 7 +- docs/deployment.md | 20 ++ docs/development.md | 8 + server/package.json | 2 +- server/scripts/migrate.ts | 21 +- server/src/db/apply-migrations.ts | 67 +++++ server/src/db/migration-reach.ts | 136 ++++++++++ server/tests/migration-journal.test.ts | 8 + .../tests/migration-reach.integration.test.ts | 206 ++++++++++++++ server/tests/migration-reach.test.ts | 251 ++++++++++++++++++ 11 files changed, 725 insertions(+), 10 deletions(-) create mode 100644 server/src/db/apply-migrations.ts create mode 100644 server/src/db/migration-reach.ts create mode 100644 server/tests/migration-reach.integration.test.ts create mode 100644 server/tests/migration-reach.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d33656e0..d12cee722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,9 +349,12 @@ jobs: # pinned by the desktop lockfile rather than the root one. - run: bun install --frozen-lockfile working-directory: desktop - # Not the db:migrate script: that one loads ../.env, which does not exist in CI. DATABASE_URL - # comes from the job env instead, which drizzle.config.ts already reads. - - run: bunx drizzle-kit migrate --config=drizzle.config.ts + # The same migrator a deployment runs, rather than `drizzle-kit migrate`: it is the one that + # refuses a database holding a stamp newer than the migrations it is about to walk past + # (server/src/db/migration-reach.ts), and a CI database that is ever reused rather than + # recreated is one of the places that can happen. Not the db:migrate script, which loads + # ../.env; that file does not exist in CI, and DATABASE_URL comes from the job env instead. + - run: bun scripts/migrate.ts working-directory: server # A passing job must include the expected test floor. Import-time failures can otherwise skip # files before their tests are registered. diff --git a/docker-compose.yml b/docker-compose.yml index f59f104cd..a7c861443 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,12 @@ services: # than relying on this variable. image: ${SERVER_IMAGE:-openbot-server:latest} pull_policy: ${IMAGE_PULL_POLICY:-build} - command: ["bun", "x", "drizzle-kit", "migrate", "--config=drizzle.config.ts"] + # The server's own migrator, which is what this image actually ships: `drizzle-kit` is a + # development dependency that `bun x` had to fetch at run time, and it is the one migrator that + # will skip a migration stamped below what the database has already recorded and still report + # success (see server/src/db/migration-reach.ts). The local stack is a developer's database and + # therefore one of the places that happens. + command: ["bun", "scripts/migrate.ts"] environment: DATABASE_URL: postgres://openbot:openbot@postgres:5432/openbot depends_on: diff --git a/docs/deployment.md b/docs/deployment.md index a695fa884..354547196 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -181,6 +181,26 @@ against an empty database. The script uses the migrator inside `drizzle-orm`, wh dependency, and keeps the same journal, so a database migrated by either is migrated. It is what this image's own start-up path and the Helm chart's migration Job both run. +**If a migration run refuses with "the migrator only runs migrations stamped after the newest one it +finds"**, this database has recorded a migration stamped later than one it is now being asked to +apply, and nothing has been applied. The migrator decides what to run by comparing journal +timestamps against the newest timestamp in `drizzle.__drizzle_migrations` and nothing else, so a +migration stamped below that mark would be walked past and the run reported as a success over the +tables it should have created. The refusal names every migration in that position. + +It happens when a database ran a branch's migrations before that branch was merged: merging renumbers +and restamps the branch's migrations to sit after the ones it merged in, and those merged-in +migrations are older than the stamp the database already holds. Development, preview and reused CI +databases are where it is met. + +Recreate the database from empty if it is disposable. If it is not, apply each named migration's SQL +by hand and record it, which leaves the high-water mark where it is and lets the next run proceed: + +```sql +insert into drizzle.__drizzle_migrations (hash, created_at) +values ('.sql>', ); +``` + ## Replicas The page snapshot a Bot resolves element references against lives in Postgres, so a second replica diff --git a/docs/development.md b/docs/development.md index f6da20edd..9598a23b1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -104,6 +104,14 @@ registered in the journal, and it is the only migration anybody should be writin CI enforces two things about this: `drizzle-kit check` for collisions and gaps between migrations, and a generate-and-fail-if-dirty probe that refuses a schema change with no migration written for it. +**If `db:migrate` refuses with "the migrator only runs migrations stamped after the newest one it +finds"**, your database ran a branch's migrations before that branch was merged. Merging renumbers +the branch's migrations to sit after the ones it merged in, and those merged-in migrations are now +stamped older than what your database already recorded — so the migrator would skip them and report +success over the tables they should have created. Nothing is applied; the refusal names them. Drop +and recreate the database, which is almost always the right answer for a development one; see +[deployment.md](deployment.md) for how to repair one you have to keep. + **If `drizzle-kit migrate` hangs and then exits non-zero with no error**, the journal names a migration file that is not there. A rebase does this: `meta/_journal.json` is a checked-in file, so restoring it can reinstate entries for migrations that were renamed. `drizzle-kit check` reports diff --git a/server/package.json b/server/package.json index f3dede843..8cc408671 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc --noEmit", "db:generate": "bun --env-file=../.env drizzle-kit generate --config=drizzle.config.ts", - "db:migrate": "bun --env-file=../.env drizzle-kit migrate --config=drizzle.config.ts", + "db:migrate": "bun --env-file=../.env scripts/migrate.ts", "dev": "bun --env-file=../.env --watch src/index.ts", "typecheck": "tsc --noEmit" }, diff --git a/server/scripts/migrate.ts b/server/scripts/migrate.ts index e4dddbeda..939466296 100644 --- a/server/scripts/migrate.ts +++ b/server/scripts/migrate.ts @@ -11,11 +11,15 @@ * server imports it anyway. It needs a connection and the folder of SQL files, both of which are in * the image, and it keeps the same `drizzle.__drizzle_migrations` journal the CLI does, so the two * are interchangeable and a database migrated by either is migrated. + * + * It is now what every path runs — the image, the Helm Job, the compose stack, CI, and `db:migrate` + * — because the two stopped being interchangeable in the direction that matters: only this one + * refuses a database holding a stamp newer than a migration it is about to walk past. See + * `src/db/migration-reach`. */ import { join } from "node:path"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; +import { applyMigrations } from "../src/db/apply-migrations"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { @@ -33,9 +37,16 @@ if (!databaseUrl) { const client = postgres(databaseUrl, { max: 1, onnotice: () => {} }); try { - await migrate(drizzle(client), { - migrationsFolder: join(import.meta.dir, "..", "drizzle"), - }); + /* + * Guarded, because the migrator's own idea of "applied" is one number. + * + * It runs a migration when the journal stamps it later than the newest stamp the database holds, + * and it has nothing to say about a migration stamped earlier that it has never run. See + * `src/db/migration-reach`: a database that ran a branch's migrations before that branch was + * merged and renumbered holds a stamp newer than the migrations the merge brought in, and the + * unguarded migrator walks past them and prints the line below anyway. + */ + await applyMigrations(client, join(import.meta.dir, "..", "drizzle")); console.info(JSON.stringify({ type: "migrations-applied", status: "ok" })); } finally { // Released whatever happened, so a failure exits rather than hanging on an open socket. diff --git a/server/src/db/apply-migrations.ts b/server/src/db/apply-migrations.ts new file mode 100644 index 000000000..64daa44e0 --- /dev/null +++ b/server/src/db/apply-migrations.ts @@ -0,0 +1,67 @@ +/** + * Apply a migration folder to a database, or refuse before touching it. + * + * Separate from `scripts/migrate.ts` so that the refusal has a test. The script is an executable — + * it reads the environment and exits — and the only way to find out what it does to a database that + * is one journal out of step is to run it against such a database, which is what + * `tests/migration-reach.integration.test.ts` does through this function. + * + * See `./migration-reach` for what is being checked and why the answer is a refusal rather than a + * repair. + */ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import type { Sql } from "postgres"; +import { + type AppliedMigration, + migrationsOutOfReach, + outOfReachRefusal, + readMigrationFolder, +} from "./migration-reach"; + +/** + * What the migrator has already recorded here, or nothing at all. + * + * `to_regclass` rather than a query that may fail: a database the migrator has never touched has no + * `drizzle` schema, and selecting from a table that is not there would be an error indistinguishable + * from a database that cannot be read. + */ +async function appliedMigrations(client: Sql): Promise { + const [present] = await client<{ table: string | null }[]>` + select to_regclass('drizzle.__drizzle_migrations')::text as table + `; + if (!present?.table) return []; + + const rows = await client<{ hash: string; created_at: string | null }[]>` + select hash, created_at from drizzle.__drizzle_migrations + `; + return rows.map((row) => ({ + hash: row.hash, + createdAt: Number(row.created_at ?? 0), + })); +} + +/** Thrown instead of migrating, when migrating would skip work and call it done. */ +export class MigrationsOutOfReachError extends Error { + constructor(message: string) { + super(message); + this.name = "MigrationsOutOfReachError"; + } +} + +export async function applyMigrations( + client: Sql, + migrationsFolder: string, +): Promise { + const folder = await readMigrationFolder(migrationsFolder); + const applied = await appliedMigrations(client); + + const outOfReach = migrationsOutOfReach(folder, applied); + if (outOfReach.length > 0) { + throw new MigrationsOutOfReachError( + outOfReachRefusal(outOfReach, folder, applied), + ); + } + + await migrate(drizzle(client), { migrationsFolder }); +} diff --git a/server/src/db/migration-reach.ts b/server/src/db/migration-reach.ts new file mode 100644 index 000000000..064754d4d --- /dev/null +++ b/server/src/db/migration-reach.ts @@ -0,0 +1,136 @@ +/** + * Whether every migration in the folder can still be reached by the migrator, on THIS database. + * + * WHAT THE MIGRATOR ACTUALLY DOES. `PgDialect.migrate` reads one row — the newest `created_at` in + * `drizzle.__drizzle_migrations` — and then runs a migration if, and only if, its journal `when` is + * greater than that number. It never looks at hashes, never looks at tags, and never notices that a + * migration it walked past was one it has never run. A migration whose `when` is at or below the + * high-water mark is therefore not "pending": it is unreachable, forever, and the migrator will + * report success without it. + * + * WHY THAT IS NOT HYPOTHETICAL. A branch that ships migrations and is then merged with `main` has + * its own migrations renumbered and restamped to sit after main's. Every database that ran the + * branch BEFORE the merge has already recorded the branch's old, higher stamp. Main's migrations + * now carry a `when` older than that mark, so they are skipped — silently — while the renumbered + * branch migrations, stamped later still, run perfectly. The database ends up missing tables that + * the migrator just said it had applied. A fresh database is unaffected, which is exactly why CI, + * which always migrates from empty, cannot see it. + * + * WHY THIS REFUSES RATHER THAN REPAIRS. Running the unreachable SQL and backfilling its row is a + * two-line change and the wrong one. The migrations that are out of reach are out of reach because + * some OTHER journal already ran against this database, and nothing here knows what that journal + * did — whether its statements created the same objects under other names, left half a schema + * behind, or were the hand-repair of an earlier failure. The honest answer to "this database cannot + * be brought up to date by running the folder forward" is to say so, loudly, with the names in + * hand, and to stop. An operator can then drop a disposable database, or repair a durable one + * deliberately. What must never happen again is `{"type":"migrations-applied","status":"ok"}` over + * tables that do not exist. + */ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** One migration in the folder, as the migrator reads it. */ +export type FolderMigration = { + tag: string; + /** The journal's `when`: the only thing the migrator compares. */ + when: number; + /** + * sha256 of the whole `.sql` file, hex. + * + * Computed exactly as `drizzle-orm/migrator`'s `readMigrationFiles` computes it — the whole file + * as read, before any statement splitting — because this is matched against the hashes that the + * migrator itself wrote into `drizzle.__drizzle_migrations`. A different recipe here would make + * every applied migration look unapplied. + */ + hash: string; +}; + +/** One row of `drizzle.__drizzle_migrations`. */ +export type AppliedMigration = { hash: string; createdAt: number }; + +type Journal = { entries: { idx: number; tag: string; when: number }[] }; + +/** + * The folder, read the way the migrator reads it: the journal decides, not the directory listing. + * + * A `.sql` file with no journal entry never runs and is not this function's business; an entry with + * no file stops the migrator dead, and `readFile` rejecting here says so in the same breath. + */ +export async function readMigrationFolder( + folder: string, +): Promise { + const journal = JSON.parse( + await readFile(join(folder, "meta", "_journal.json"), "utf8"), + ) as Journal; + + return Promise.all( + journal.entries.map(async (entry) => ({ + tag: entry.tag, + when: entry.when, + hash: createHash("sha256") + .update(await readFile(join(folder, `${entry.tag}.sql`), "utf8")) + .digest("hex"), + })), + ); +} + +/** + * The migrations this database can no longer reach: never applied, and stamped at or below the + * high-water mark the migrator will compare them against. + * + * An empty `applied` is a database the migrator has never touched, where everything is reachable by + * definition — a fresh database can never be in this trouble, and saying so explicitly keeps the + * check from having an opinion about the one case it has no evidence for. + */ +export function migrationsOutOfReach( + folder: readonly FolderMigration[], + applied: readonly AppliedMigration[], +): FolderMigration[] { + if (applied.length === 0) return []; + + const highWaterMark = Math.max(...applied.map((row) => row.createdAt)); + const appliedHashes = new Set(applied.map((row) => row.hash)); + + return folder.filter( + (migration) => + migration.when <= highWaterMark && !appliedHashes.has(migration.hash), + ); +} + +/** + * The refusal, written for whoever is reading a failed deploy at an unreasonable hour. + * + * It names the mark and what set it, every migration that cannot be reached, and the two ways out, + * because the one thing an operator cannot work out from a stack trace is that the database is + * *ahead* of the folder rather than behind it. + */ +export function outOfReachRefusal( + outOfReach: readonly FolderMigration[], + folder: readonly FolderMigration[], + applied: readonly AppliedMigration[], +): string { + const highWaterMark = Math.max(...applied.map((row) => row.createdAt)); + const inFolder = new Set(folder.map((migration) => migration.hash)); + /* + * Rows this folder cannot account for. Not a failure on its own — a squashed or deleted migration + * leaves them too — but when migrations are out of reach these rows are almost always the reason, + * so the count is the fastest thing an operator can act on. + */ + const unknown = applied.filter((row) => !inFolder.has(row.hash)).length; + const mark = + folder.find((migration) => migration.when === highWaterMark)?.tag ?? + "a migration this folder does not contain"; + + return [ + `This database has already recorded a migration stamped ${highWaterMark} (${mark}), and the migrator only runs migrations stamped after the newest one it finds.`, + `${outOfReach.length === 1 ? "This migration is" : `These ${outOfReach.length} migrations are`} stamped at or below that mark and ${outOfReach.length === 1 ? "has" : "have"} never been applied here, so running the migrator would skip ${outOfReach.length === 1 ? "it" : "them"} and report success over the schema ${outOfReach.length === 1 ? "it" : "they"} should have created:`, + ...outOfReach.map( + (migration) => ` - ${migration.tag} (stamped ${migration.when})`, + ), + unknown === 0 + ? "Every applied migration is still present in this folder." + : `${unknown} applied ${unknown === 1 ? "migration is" : "migrations are"} not in this folder at all, which is what a renumbered or rewritten migration history looks like from here: this database ran a journal that no longer exists.`, + "Nothing has been applied. Recreate the database from empty if it is disposable (a development, preview or CI database usually is); otherwise apply the SQL above by hand and record each one with `insert into drizzle.__drizzle_migrations (hash, created_at) values (, )`, which leaves the high-water mark where it is.", + ].join("\n"); +} diff --git a/server/tests/migration-journal.test.ts b/server/tests/migration-journal.test.ts index 42ddf52ee..2b54e5231 100644 --- a/server/tests/migration-journal.test.ts +++ b/server/tests/migration-journal.test.ts @@ -16,6 +16,14 @@ import { readdir, readFile } from "node:fs/promises"; * and the only symptom was an integration test failing with `relation "skill_tools" does not exist`. * * Nothing else in the build would have caught that, which is why this is a test rather than a note. + * + * WHAT THIS FILE CANNOT SEE, so that nobody reads it as covering more than it does: every check + * here reads ONE journal and asks whether it agrees with itself. The same swallowing happens + * between journals — a merge that renumbers a branch's migrations gives the migrations it merged in + * a `when` older than the stamp a pre-merge database has already recorded, and those are skipped on + * that database while this file, and a fresh database, and CI, all stay green. That half lives in + * `migration-reach.test.ts` and in the guard it tests, which runs against the database rather than + * against the folder, because it is the only place the evidence exists. */ type Entry = { idx: number; tag: string; when: number }; diff --git a/server/tests/migration-reach.integration.test.ts b/server/tests/migration-reach.integration.test.ts new file mode 100644 index 000000000..90cbb4688 --- /dev/null +++ b/server/tests/migration-reach.integration.test.ts @@ -0,0 +1,206 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import postgres from "postgres"; +import { + applyMigrations, + MigrationsOutOfReachError, +} from "../src/db/apply-migrations"; +import { testDatabaseUrl } from "./support/database"; + +/** + * The renumber, run for real, because the whole defect is what a real migrator does to a real + * database and nothing short of one can show it. + * + * A database is created per case and dropped afterwards. A schema inside the shared test database + * would not do: the migrator's own bookkeeping lives at `drizzle.__drizzle_migrations`, and these + * cases are entirely about what that one table already contains, so they need a database whose + * contents nothing else wrote. + * + * The migrations below are two statements long on purpose. The journal that matters here is the + * sequence of stamps, not the SQL, and this file has no business taking seconds to re-run the + * fifty-odd real ones twice per case. + */ + +const server = new URL(testDatabaseUrl()); +const administrative = new URL(server.href); +administrative.pathname = "/postgres"; + +const created: string[] = []; + +async function throwawayDatabase(): Promise { + const name = `openbot_reach_${Math.random().toString(36).slice(2, 10)}`; + const admin = postgres(administrative.href, { max: 1, onnotice: () => {} }); + try { + await admin.unsafe(`CREATE DATABASE "${name}"`); + } finally { + await admin.end({ timeout: 5 }); + } + created.push(name); + const url = new URL(server.href); + url.pathname = `/${name}`; + return url.href; +} + +afterAll(async () => { + const admin = postgres(administrative.href, { max: 1, onnotice: () => {} }); + try { + for (const name of created) { + await admin.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); + } + } finally { + await admin.end({ timeout: 5 }); + } +}); + +type Entry = { tag: string; when: number; sql: string }; + +/** A migration folder on disk, in the shape `readMigrationFiles` insists on. */ +async function folderOf(entries: Entry[]): Promise { + const directory = await mkdtemp(join(tmpdir(), "migration-reach-")); + await mkdir(join(directory, "meta"), { recursive: true }); + for (const entry of entries) { + await writeFile(join(directory, `${entry.tag}.sql`), entry.sql); + } + await writeFile( + join(directory, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: entries.map((entry, idx) => ({ + idx, + version: "7", + when: entry.when, + tag: entry.tag, + breakpoints: true, + })), + }), + ); + return directory; +} + +const first: Entry = { + tag: "0000_first", + when: 1_000_000, + sql: 'CREATE TABLE "first" ("id" text PRIMARY KEY);\n', +}; + +/** The branch's own migration, as it shipped before the merge renumbered it. */ +const asShipped: Entry = { + tag: "0001_branch", + when: 3_000_000, + sql: 'CREATE TABLE "branch" ("id" text PRIMARY KEY);\n', +}; + +const exists = async (client: postgres.Sql, table: string) => { + const [row] = await client<{ present: string | null }[]>` + select to_regclass(${`public.${table}`})::text as present + `; + return row?.present !== null && row?.present !== undefined; +}; + +describe("a database that ran a branch before the branch was merged", () => { + test("is refused, rather than migrated past the work it cannot reach", async () => { + const url = await throwawayDatabase(); + const client = postgres(url, { max: 1, onnotice: () => {} }); + try { + await applyMigrations(client, await folderOf([first, asShipped])); + expect(await exists(client, "branch")).toBe(true); + + /* + * The merge: main's migration takes the number the branch's had, stamped when main's author + * generated it — before the branch shipped — and the branch's work is restamped after both. + * The unguarded migrator runs `0002`, walks past `0001`, and reports success. + */ + const merged = await folderOf([ + first, + { + tag: "0001_from_main", + when: 2_000_000, + sql: 'CREATE TABLE "from_main" ("id" text PRIMARY KEY);\n', + }, + { + ...asShipped, + tag: "0002_branch_renumbered", + when: 4_000_000, + sql: 'ALTER TABLE "branch" ADD COLUMN "renumbered" boolean;\n', + }, + ]); + + const refusal = await applyMigrations(client, merged).then( + () => undefined, + (error: unknown) => error, + ); + expect(refusal).toBeInstanceOf(MigrationsOutOfReachError); + expect((refusal as Error).message).toContain( + "0001_from_main (stamped 2000000)", + ); + + // And refused before anything ran, rather than part way through: the database is exactly as + // the branch left it, which is the state an operator can reason about. + expect(await exists(client, "from_main")).toBe(false); + const [row] = await client<{ count: string }[]>` + select count(*)::text as count from drizzle.__drizzle_migrations + `; + expect(row?.count).toBe("2"); + } finally { + await client.end({ timeout: 5 }); + } + }); + + test("is refused before the statement that would have failed on it", async () => { + /* + * The other half, and the one that at least announces itself. Where the renumbered migration + * recreates a table the branch already created, the unguarded migrator dies on `CREATE TABLE` + * with a duplicate — after having skipped the migration below the mark, so that the obvious + * hand repair (drop the table, run again) is what turns a loud failure into a silent one. + */ + const url = await throwawayDatabase(); + const client = postgres(url, { max: 1, onnotice: () => {} }); + try { + await applyMigrations(client, await folderOf([first, asShipped])); + + const merged = await folderOf([ + first, + { + tag: "0001_from_main", + when: 2_000_000, + sql: 'CREATE TABLE "from_main" ("id" text PRIMARY KEY);\n', + }, + { ...asShipped, tag: "0002_branch_renumbered", when: 4_000_000 }, + ]); + + await expect(applyMigrations(client, merged)).rejects.toThrow( + MigrationsOutOfReachError, + ); + } finally { + await client.end({ timeout: 5 }); + } + }); +}); + +describe("an ordinary database", () => { + test("migrates from empty and then forward, without being refused", async () => { + const url = await throwawayDatabase(); + const client = postgres(url, { max: 1, onnotice: () => {} }); + try { + await applyMigrations(client, await folderOf([first])); + await applyMigrations( + client, + await folderOf([ + first, + { + tag: "0001_next", + when: 2_000_000, + sql: 'CREATE TABLE "next" ("id" text PRIMARY KEY);\n', + }, + ]), + ); + + expect(await exists(client, "next")).toBe(true); + } finally { + await client.end({ timeout: 5 }); + } + }); +}); diff --git a/server/tests/migration-reach.test.ts b/server/tests/migration-reach.test.ts new file mode 100644 index 000000000..0006d0e68 --- /dev/null +++ b/server/tests/migration-reach.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type AppliedMigration, + type FolderMigration, + migrationsOutOfReach, + outOfReachRefusal, + readMigrationFolder, +} from "../src/db/migration-reach"; + +/** + * The check that `migration-journal.test.ts` cannot make. + * + * That file reads one journal and asks whether it is internally consistent — stamps increasing, a + * file for every entry, a snapshot for every file. Every one of those passed over the defect this + * file exists for, because the defect is not inside the journal. It is between the journal in the + * repository and the stamps that a PREVIOUS journal already wrote into a running database: the + * merge that renumbered this branch's migrations gave main's `0053_identity_links` a `when` older + * than the stamp a pre-merge database had already recorded for the branch's own work, and the + * migrator skips what it finds below the newest stamp without a word. `migrate` printed + * `{"type":"migrations-applied","status":"ok"}` while `identity_links` and + * `identity_link_challenges` did not exist. + * + * No journal, read alone, can show that. So the check lives in the migrator's path instead, and + * this is its test. + */ + +const migration = ( + tag: string, + when: number, + hash: string, +): FolderMigration => ({ tag, when, hash }); + +const applied = (hash: string, createdAt: number): AppliedMigration => ({ + hash, + createdAt, +}); + +describe("migrations out of reach", () => { + test("finds none on a database the migrator has never touched", () => { + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_second", 2000, "b"), + ]; + + expect(migrationsOutOfReach(folder, [])).toEqual([]); + }); + + test("finds none when the database is simply behind the folder", () => { + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_second", 2000, "b"), + migration("0002_third", 3000, "c"), + ]; + + // The normal case, and the one that must never be refused: two applied, one pending. + expect( + migrationsOutOfReach(folder, [applied("a", 1000), applied("b", 2000)]), + ).toEqual([]); + }); + + test("finds none when the database is exactly up to date", () => { + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_second", 2000, "b"), + ]; + + expect( + migrationsOutOfReach(folder, [applied("a", 1000), applied("b", 2000)]), + ).toEqual([]); + }); + + test("names a migration stamped below the mark that has never been applied", () => { + /* + * The renumber, in miniature. The database ran a branch whose migration was stamped 3000; the + * merge then brought in a migration from main stamped 2000 and restamped the branch's work + * 4000. The migrator compares against 3000, so 2000 is walked past, 4000 runs, and the run is + * reported as a success that applied everything. + */ + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_from_main", 2000, "main"), + migration("0002_branch_renumbered", 4000, "renumbered"), + ]; + const database = [applied("a", 1000), applied("branch-as-shipped", 3000)]; + + expect(migrationsOutOfReach(folder, database)).toEqual([ + migration("0001_from_main", 2000, "main"), + ]); + }); + + test("names a migration stamped exactly on the mark", () => { + /* + * The comparison the migrator makes is `recorded < when`, so a migration stamped exactly at the + * mark is skipped as surely as one stamped below it. Off by one here would be a check that + * passes a database it cannot bring up to date. + */ + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_tied", 2000, "tied"), + ]; + + expect(migrationsOutOfReach(folder, [applied("a", 2000)])).toEqual([ + migration("0001_tied", 2000, "tied"), + ]); + }); + + test("names every unreachable migration, not just the first", () => { + const folder = [ + migration("0001_one", 1000, "one"), + migration("0002_two", 2000, "two"), + migration("0003_three", 5000, "three"), + ]; + + expect( + migrationsOutOfReach(folder, [applied("something-else", 3000)]), + ).toEqual([ + migration("0001_one", 1000, "one"), + migration("0002_two", 2000, "two"), + ]); + }); + + test("is not fooled by a migration whose file has been rewritten", () => { + // Applied under one hash, present in the folder under another: the database ran different SQL + // from the file that now claims to be it, and the difference will never be applied. + const folder = [migration("0000_first", 1000, "rewritten")]; + + expect(migrationsOutOfReach(folder, [applied("original", 1000)])).toEqual([ + migration("0000_first", 1000, "rewritten"), + ]); + }); +}); + +describe("the refusal", () => { + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_from_main", 2000, "main"), + migration("0002_branch_renumbered", 4000, "renumbered"), + ]; + const database = [applied("a", 1000), applied("branch-as-shipped", 3000)]; + const text = outOfReachRefusal( + migrationsOutOfReach(folder, database), + folder, + database, + ); + + test("names the migration that cannot be reached", () => { + expect(text).toContain("0001_from_main (stamped 2000)"); + }); + + test("names the mark that puts it out of reach", () => { + expect(text).toContain("stamped 3000"); + }); + + test("says that the database ran a journal this folder no longer has", () => { + expect(text).toContain("1 applied migration is not in this folder at all"); + }); + + test("says that nothing was applied", () => { + expect(text).toContain("Nothing has been applied."); + }); +}); + +describe("reading a migration folder", () => { + test("hashes the whole file the way the migrator does", async () => { + const directory = await mkdtemp(join(tmpdir(), "migration-reach-")); + const sql = 'CREATE TABLE "one" ("id" text);\n'; + await writeFile(join(directory, "0000_one.sql"), sql); + await Bun.write( + join(directory, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: [ + { + idx: 0, + version: "7", + when: 1000, + tag: "0000_one", + breakpoints: true, + }, + ], + }), + ); + + expect(await readMigrationFolder(directory)).toEqual([ + { + tag: "0000_one", + when: 1000, + // Whole file, before any `--> statement-breakpoint` split, which is what + // `drizzle-orm/migrator` records and therefore what the database holds. + hash: createHash("sha256").update(sql).digest("hex"), + }, + ]); + }); +}); + +describe("the migrations this repository ships", () => { + test("reach a database that has never been migrated", async () => { + const folder = await readMigrationFolder( + new URL("../drizzle/", import.meta.url).pathname, + ); + + expect(migrationsOutOfReach(folder, [])).toEqual([]); + }); + + test("reach a database that is up to date with them", async () => { + const folder = await readMigrationFolder( + new URL("../drizzle/", import.meta.url).pathname, + ); + + expect( + migrationsOutOfReach( + folder, + folder.map((entry) => applied(entry.hash, entry.when)), + ), + ).toEqual([]); + }); + + test("do not reach a database that ran the arcade branch before it was merged", async () => { + /* + * The incident, pinned, because it is the only shape of this failure anybody has actually met. + * + * Before the merge, the arcade work shipped as `0053_adorable_frank_castle` (1791773482676) and + * `0054_spicy_wallflower` (1791774497487). The merge renumbered it to `0057_arcade_provider` + * (1791908858814) and put main's `0053_identity_links` (1791547580808) in its place — a stamp + * OLDER than what such a database had already recorded. Everything up to `0052` is byte for + * byte the same file on both sides of the merge, so those rows are built from this folder. + * + * The expected answer is one name, and it is the migration whose two tables were missing from + * the reproduction: `identity_links` and `identity_link_challenges`. + */ + const folder = await readMigrationFolder( + new URL("../drizzle/", import.meta.url).pathname, + ); + const asShipped = [ + ...folder + .filter((entry) => entry.when <= 1791396757748) + .map((entry) => applied(entry.hash, entry.when)), + applied("0053_adorable_frank_castle", 1791773482676), + applied("0054_spicy_wallflower", 1791774497487), + ]; + + expect( + migrationsOutOfReach(folder, asShipped).map((entry) => entry.tag), + ).toEqual(["0053_identity_links"]); + }); +}); From 62d5cb7fa940fa8068f3708a3afedbcc1c960b06 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:42:54 +0200 Subject: [PATCH 118/232] fix(arcade): cancel the authorization the path names, not whatever the actor holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DELETE /servers/:id/authorize` resolved `:id` through `brokeredAccountFor` and then threw the resolved id away: `authorizationAttemptFor` is keyed on the actor alone, so a cancel issued from app B deleted an in-flight attempt on app A and answered `{cancelled: true}` as though it had done what was asked. That is the mint/claim/complete correlation this PR holds from the minting end, broken from the cancelling end. The route now compares the attempt's `server_id` against the raw path parameter — the same value `POST /servers/:id/authorize` writes when it claims the row — and answers `{cancelled: false}` when they differ, the same non-error as there being no attempt at all. The deletion still matches on `(actor_id, created_at)`, so the existing race guard is untouched. No symbol was added, renamed, resignatured or removed: `authorizationAttemptFor` and `discardAuthorizationAttempt` keep their signatures, so every call site of both (routes.ts:2681 and :2828; store.ts's own definitions; the mint's definite-failure discard at routes.ts:2628; the mocks in server/tests/arcade-authorize-cancel.test.ts and server/tests/arcade-verify.test.ts; server/tests/arcade-attempt-store.integration.test.ts) is unaffected. Audited every other route in routes.ts: no other authorization-lifecycle route resolves an identifier and then acts on an actor-keyed lookup. `/arcade/verify` takes no path identifier and binds on `(actor, auth_id)` deliberately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/routes.ts | 21 +++++++++- server/tests/arcade-authorize-cancel.test.ts | 40 +++++++++++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 95048893f..3f854c1d9 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2668,9 +2668,24 @@ export function createPluginRoutes( * `actorId` IS THE SESSION SUBJECT (`users.id`), NEVER A CREDENTIAL ACTOR. BEHIND `requireUser` * AND NOT ADMIN-GATED AT THE ROUTE, as the disconnect route above: `brokeredAccountFor` is where * a Shared app is refused to anybody but an administrator. + * + * AND IT CANCELS THE ATTEMPT THE PATH NAMES, NEVER MERELY "WHATEVER THIS ACTOR IS HOLDING". The + * attempt key is the actor alone, so the lookup can only answer that one row — but the request + * names an app, and an attempt standing on a DIFFERENT app is not the thing being cancelled. + * Deleting it anyway killed an in-flight consent begun from another screen and answered + * `{ cancelled: true }` as though it had done what was asked, which is the same broken + * correlation the mint/claim/complete path exists to hold from the other end. A standing attempt + * on another app is therefore the `{ cancelled: false }` of "no attempt for THIS app", which is + * the same answer, and the same non-error, as there being no attempt at all. + * + * THE SERVER IS COMPARED AGAINST THE RAW PATH PARAMETER, which is exactly what + * `POST /servers/:id/authorize` writes into `server_id` when it claims the row. Comparing against + * anything derived — the toolkit, the resolved title — would be two spellings of one fact and a + * place for them to drift apart. */ routes.delete("/servers/:id/authorize", requireUser, async (context) => { - const resolved = await brokeredAccountFor(context, context.req.param("id")); + const serverId = context.req.param("id"); + const resolved = await brokeredAccountFor(context, serverId); if (resolved.refusal) { return context.json( { error: resolved.refusal.error }, @@ -2679,7 +2694,9 @@ export function createPluginRoutes( } const actorId = context.var.actor.id; const attempt = await store.authorizationAttemptFor(actorId); - if (!attempt) return context.json({ cancelled: false }); + if (!attempt || attempt.serverId !== serverId) { + return context.json({ cancelled: false }); + } const cancelled = await store.discardAuthorizationAttempt({ actorId, createdAt: attempt.createdAt, diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 5fae74c55..0a20dbd12 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -24,6 +24,7 @@ const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); const personalApp = `arcade-cp-${suite}`; const sharedApp = `arcade-cs-${suite}`; +const otherApp = `arcade-co-${suite}`; const SHARED_VENDOR_ID = `openbot-deployment:acme-${suite}:R`; const ACTION = "Gmail.SendEmail"; const SECRET_URL = "https://vendor.example/consent?secret=bearer-capability"; @@ -163,18 +164,28 @@ beforeAll(async () => { accountMode: "shared", sharedVendorUserId: SHARED_VENDOR_ID, }, + { + id: otherApp, + title: "Slack", + vendor: "Arcade", + url: `arcade://O${suite}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "personal", + }, ]); - await database - .insert(mcpTools) - .values( - [personalApp, sharedApp].map((serverId) => ({ serverId, name: ACTION })), - ); + await database.insert(mcpTools).values( + [personalApp, sharedApp, otherApp].map((serverId) => ({ + serverId, + name: ACTION, + })), + ); }); afterAll(async () => { await database .delete(mcpServers) - .where(inArray(mcpServers.id, [personalApp, sharedApp])); + .where(inArray(mcpServers.id, [personalApp, sharedApp, otherApp])); if (actors.length) { await database .delete(pendingAuthorizations) @@ -236,6 +247,23 @@ describe("DELETE /servers/:id/authorize", () => { expect(await attemptsFor(actor)).toEqual([]); }); + test("a cancel naming one app does not cancel this actor's attempt on another", async () => { + const actor = person("otherapp"); + const app = appAs("user", actor); + expect((await authorize(app, personalApp)).status).toBe(200); + const [before] = await attemptsFor(actor); + expect(before?.serverId).toBe(personalApp); + + const response = await cancel(app, otherApp); + expect(response.status).toBeLessThan(300); + expect(await response.json()).toEqual({ cancelled: false }); + + const [after] = await attemptsFor(actor); + expect(after?.serverId).toBe(personalApp); + expect(after?.createdAt.getTime()).toBe(before?.createdAt.getTime()); + expect(after?.authId).toBe("ar_minted_1"); + }); + test("a cancel issued for an OLD created_at does not remove a NEWER attempt by the same actor", async () => { const actor = person("stale"); const plain = appAs("user", actor); From 8b0f38029e3724b5850c1885bd285cb343db5943 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:43:06 +0200 Subject: [PATCH 119/232] fix(broker): a key of nothing is no key, decided where "configured" is decided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `activeProvider` tested the two API keys with `!== undefined`, so `ARCADE_API_KEY=` with nothing after it, or a key that is all whitespace, counted as a CONFIGURED provider. `selectProviderWiring` then built a vendor client from `""` — the failure surfaced at the vendor, on somebody's first connect, instead of at the setting that caused it. Only `server/src/config.ts`'s `optional()` trim prevented this in the deployed path, and nothing stated or tested that dependency. `activeProvider` takes config-shaped VALUES rather than an environment, so every other caller (a test, an embedder, a future loader) reached it untrimmed. What "configured" means for a credential is this function's own question, so it is now answered there: a key is present only if it is defined and not blank after trimming. RED-GREEN: the new cases failed against the unfixed function — `activeProvider({ arcadeApiKey: "" })` returned "arcade", and `selectProviderWiring({ arcadeApiKey: "" }, …)` built an Arcade client (factory called with a zero-length key) — and pass after. Same-pattern audit of the whole of broker.ts: no other credential, key or identifier is tested for presence in a way that admits a blank. `brokerProvider` is a two-member union validated in config, not a free string; `brokerReturnUrl` already refuses a whitespace-only address with `returnUrl.trim() === ""`; `isFieldScheme` and `flagOf` test vendor-sent values against closed sets, not presence. `provider-wiring.ts`'s `c.arcadeApiKey !== undefined` narrowing guards are now unreachable with a blank key and are left as type narrowing. No symbol added, renamed, resignatured or removed: `activeProvider` keeps its name and signature, so all three call sites (server/src/index.ts:884, server/src/plugins/provider-wiring.ts:44, server/tests/provider-selection.test.ts) are unchanged. The startup refusal's sentence now reads "is not set to a key", so that an operator who typed `ARCADE_API_KEY=` is not told the variable is missing; docs/plugins/arcade.md quotes that sentence verbatim and was updated with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- docs/plugins/arcade.md | 2 +- server/src/plugins/broker.ts | 19 ++++++-- server/src/plugins/provider-wiring.ts | 6 +-- server/tests/provider-selection.test.ts | 60 +++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/docs/plugins/arcade.md b/docs/plugins/arcade.md index 69ddc7968..60d1a473f 100644 --- a/docs/plugins/arcade.md +++ b/docs/plugins/arcade.md @@ -41,7 +41,7 @@ vendor's key. With both set and the variable unset, neither is active. Two refusals protect this, and both are deliberate: -- **A named provider whose key is not set stops the server at startup.** An operator sees: _OPENBOT_BROKER_PROVIDER=arcade names a provider whose ARCADE_API_KEY is not set. Set ARCADE_API_KEY, or change or unset OPENBOT_BROKER_PROVIDER._ It does not fall back to a deployment with no broker, because that would send somebody looking for a broken vendor instead of at their own environment. +- **A named provider whose key is not set stops the server at startup.** An operator sees: _OPENBOT_BROKER_PROVIDER=arcade names a provider whose ARCADE_API_KEY is not set to a key. Set ARCADE_API_KEY to this deployment's key, or change or unset OPENBOT_BROKER_PROVIDER._ It does not fall back to a deployment with no broker, because that would send somebody looking for a broken vendor instead of at their own environment. A key that is empty or all whitespace — `ARCADE_API_KEY=` with nothing after it — is no key: that provider counts as unconfigured, and no client is ever built on an empty credential. - **Activating a provider is refused while the other one still holds connections.** It is keyed on connection rows, not on keys. The message names the provider being activated, how many connections still belong to the other, and tells the operator to keep both keys set and end those connections through the other provider's sunset flow first. Switching by editing the environment would leave accounts nobody can revoke. ## Setting up the Arcade project diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 2107882c4..a47517f39 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -999,17 +999,28 @@ export function brokerSentence(error: unknown): string | null { * One key set selects that provider. Both set is legal and expected, since ending an account at * the old vendor needs its key, so `brokerProvider` decides and unset means `null`. A * `brokerProvider` naming a provider whose key is unset is refused (`null`), never selected. + * + * A KEY OF NOTHING IS NO KEY, AND THIS DECIDES THAT ITSELF. `ARCADE_API_KEY=` with nothing after + * it, or a value that is all whitespace, is an operator typo and not a configured provider: a + * `!== undefined` test calls it configured, and `./provider-wiring` goes on to build a vendor + * client on an empty credential, which fails later and somewhere else — at the vendor, on + * somebody's first connect — rather than here, where the setting is. `./config` trims every + * optional environment value and so happens to turn those into `undefined` today, but that is one + * incidental guard in a distant module standing between a typo and a client built on "", and this + * function takes config-shaped VALUES rather than an environment, so every other caller (a test, + * an embedder, a future loader) reaches it untrimmed. What "configured" means for a credential is + * this function's own question, so it is answered here. */ export function activeProvider(c: { composioApiKey?: string; arcadeApiKey?: string; brokerProvider?: BrokerProvider; }): BrokerProvider | null { - const composio = c.composioApiKey !== undefined; - const arcade = c.arcadeApiKey !== undefined; + const credential = (key: string | undefined): boolean => + key !== undefined && key.trim() !== ""; const configured: BrokerProvider[] = []; - if (composio) configured.push("composio"); - if (arcade) configured.push("arcade"); + if (credential(c.composioApiKey)) configured.push("composio"); + if (credential(c.arcadeApiKey)) configured.push("arcade"); const named = c.brokerProvider; if (named !== undefined) return configured.includes(named) ? named : null; return configured.length === 1 ? configured[0] : null; diff --git a/server/src/plugins/provider-wiring.ts b/server/src/plugins/provider-wiring.ts index 971d9bd4f..013813481 100644 --- a/server/src/plugins/provider-wiring.ts +++ b/server/src/plugins/provider-wiring.ts @@ -25,8 +25,8 @@ export type ProviderWiring = { * Chooses ONE provider from configuration and builds only that provider's client. * * `activeProvider` answers `null` in three situations: no keys, both keys with nothing named, and - * a named provider whose key is unset. The first two are legal states. The third is an operator - * typo, and degrading it to a deployment with no broker would send them looking for a broken + * a named provider whose key is unset or blank. The first two are legal states. The third is an + * operator typo, and degrading it to a deployment with no broker would send them looking for a broken * vendor instead of at their own environment, so it is refused here, naming the variables and * never a value. */ @@ -46,7 +46,7 @@ export function selectProviderWiring( const keyName = c.brokerProvider === "arcade" ? "ARCADE_API_KEY" : "COMPOSIO_API_KEY"; throw new Error( - `OPENBOT_BROKER_PROVIDER=${c.brokerProvider} names a provider whose ${keyName} is not set. Set ${keyName}, or change or unset OPENBOT_BROKER_PROVIDER.`, + `OPENBOT_BROKER_PROVIDER=${c.brokerProvider} names a provider whose ${keyName} is not set to a key. Set ${keyName} to this deployment's key, or change or unset OPENBOT_BROKER_PROVIDER.`, ); } if (provider === "arcade" && c.arcadeApiKey !== undefined) { diff --git a/server/tests/provider-selection.test.ts b/server/tests/provider-selection.test.ts index a4e19b941..7911e8aae 100644 --- a/server/tests/provider-selection.test.ts +++ b/server/tests/provider-selection.test.ts @@ -39,6 +39,42 @@ describe("activeProvider", () => { expect(activeProvider({ brokerProvider: "arcade" })).toBe(null); }); + /** + * A key is a CREDENTIAL, so a key of nothing is no key. `ARCADE_API_KEY=` with nothing after it, + * or a value that is all whitespace, is an operator typo rather than a configured provider, and + * selecting on it builds a vendor client on an empty credential. `../src/config` happens to trim + * today, but that is one distant guard and not this function's answer, so the config values here + * are constructed directly to put the blank key in front of `activeProvider` itself. + */ + test("an empty or whitespace-only key is not a configured provider", () => { + expect(activeProvider({ arcadeApiKey: "" })).toBe(null); + expect(activeProvider({ composioApiKey: "" })).toBe(null); + expect(activeProvider({ arcadeApiKey: " " })).toBe(null); + expect(activeProvider({ composioApiKey: "\t\n " })).toBe(null); + }); + + test("a blank key beside a real one leaves the real one unambiguous", () => { + expect(activeProvider({ composioApiKey: "c", arcadeApiKey: "" })).toBe( + "composio", + ); + expect(activeProvider({ composioApiKey: " ", arcadeApiKey: "a" })).toBe( + "arcade", + ); + }); + + test("a brokerProvider whose key is blank is refused, like one that is unset", () => { + expect(activeProvider({ arcadeApiKey: "", brokerProvider: "arcade" })).toBe( + null, + ); + expect( + activeProvider({ + composioApiKey: "c", + arcadeApiKey: " ", + brokerProvider: "arcade", + }), + ).toBe(null); + }); + test("a brokerProvider whose key is unset is refused, not selected", () => { expect( activeProvider({ composioApiKey: "c", brokerProvider: "arcade" }), @@ -170,6 +206,30 @@ describe("selectProviderWiring", () => { expect(wiring.install).toEqual({ composio: null, arcade: null }); }); + test("a blank key builds no client at all", () => { + const f = factories(); + const wiring = selectProviderWiring({ arcadeApiKey: "" }, f.create); + expect(wiring.provider).toBeNull(); + expect(wiring.broker).toBeNull(); + expect(wiring.install).toEqual({ composio: null, arcade: null }); + expect(f.calls).toEqual([]); + const g = factories(); + const composio = selectProviderWiring({ composioApiKey: " " }, g.create); + expect(composio.provider).toBeNull(); + expect(g.calls).toEqual([]); + }); + + test("a named provider whose key is blank is surfaced, not built from nothing", () => { + const f = factories(); + expect(() => + selectProviderWiring( + { arcadeApiKey: "", brokerProvider: "arcade" }, + f.create, + ), + ).toThrow(/ARCADE_API_KEY/); + expect(f.calls).toEqual([]); + }); + test("a named provider whose key is unset is surfaced, not treated as no broker", () => { const f = factories(); expect(() => From 7cc00a76ca3ad804290f111423f8554f8a1a5c49 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:43:21 +0200 Subject: [PATCH 120/232] fix(broker): refuse an activation count that is not a count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assertActivatable` tested `otherProviderRows <= 0` and returned. `NaN <= 0` is false, so a NaN did not return — it fell through to the refusal sentence and reached a person as the literal words "NaN brokered connections". A negative or fractional count did the same. Whatever produced such a value was not counting rows, and not knowing how many rows the other provider holds is not the same as knowing it holds none, so the guard now refuses on any count that is not a whole non-negative number of rows, and says the count could not be taken instead of repeating the sentinel back. The zero case is now `=== 0`, so the only path that returns is a real zero. The second half of the finding — that the call site's `ne(provider, wired)` skips NULL-provider rows — does not hold: `brokered_connections.provider` is NOT NULL in the schema and in migration 0052, and a NULL insert is refused by the database, so there is no row the inequality can hide. Rather than add an `IS NULL` arm that can never match, a test in arcade-provider-check pins the NOT NULL, so dropping it fails loudly next to the reason the count needs it. Call sites of `assertActivatable` (signature and name unchanged): the startup activation check in server/src/index.ts:812, and server/tests/provider-selection.test.ts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/broker.ts | 23 ++++++++++++++++-- server/tests/arcade-provider-check.test.ts | 22 +++++++++++++++++ server/tests/provider-selection.test.ts | 28 ++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 2107882c4..457537588 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -1031,9 +1031,28 @@ export function assertActivatable( candidate: BrokerProvider, otherProviderRows: number, ): void { - if (otherProviderRows <= 0) return; const other = candidate === "arcade" ? "Composio" : "Arcade"; + const self = candidate === "arcade" ? "Arcade" : "Composio"; + /* + * A COUNT THAT IS NOT A COUNT REFUSES, AND IS NEVER REPEATED BACK. `NaN <= 0` is false, so a + * `<= 0` test alone lets a NaN through to the sentence below, where it reads as the literal + * words "NaN brokered connections" — a refusal that tells a person a number they cannot act on. + * A negative or fractional count is the same failure wearing different clothes: whatever + * produced it was not counting rows. So the only counts allowed past here are the ones a row + * count can actually be, and anything else refuses saying what is true — the count was not + * taken — rather than printing the sentinel it was handed. + * + * REFUSING, NOT PROCEEDING, because this guard exists to keep a deployment from reaching the + * vendor with rows it could no longer revoke. Not knowing how many rows the other provider + * holds is not the same as knowing it holds none. + */ + if (!Number.isInteger(otherProviderRows) || otherProviderRows < 0) { + throw new BrokerRefusalError( + `${self} cannot be activated: the connections belonging to ${other} could not be counted. Keep both API keys set, check that the database is reachable, and start again; activating without that count could leave accounts nobody can revoke.`, + ); + } + if (otherProviderRows === 0) return; throw new BrokerRefusalError( - `${candidate === "arcade" ? "Arcade" : "Composio"} cannot be activated while ${otherProviderRows} brokered connection${otherProviderRows === 1 ? "" : "s"} still belong${otherProviderRows === 1 ? "s" : ""} to ${other}. Keep both API keys set, and end those connections through the ${other} sunset flow first; switching providers by changing the environment would leave accounts nobody can revoke.`, + `${self} cannot be activated while ${otherProviderRows} brokered connection${otherProviderRows === 1 ? "" : "s"} still belong${otherProviderRows === 1 ? "s" : ""} to ${other}. Keep both API keys set, and end those connections through the ${other} sunset flow first; switching providers by changing the environment would leave accounts nobody can revoke.`, ); } diff --git a/server/tests/arcade-provider-check.test.ts b/server/tests/arcade-provider-check.test.ts index d43219cf0..1b54dcd7d 100644 --- a/server/tests/arcade-provider-check.test.ts +++ b/server/tests/arcade-provider-check.test.ts @@ -52,4 +52,26 @@ describe("brokered_connections.provider", () => { ), ).toMatch(/brokered_connections_provider_check/); }); + + /** + * A row with no provider cannot exist, which is what makes the startup activation count sound. + * + * The count behind `assertActivatable` (server/src/index.ts) asks for the rows that are + * `ne(provider, )`. A SQL inequality never matches NULL, so a nullable + * `provider` would make exactly the ambiguous rows invisible to the guard that exists to see + * them. It is not nullable, and this is the test that keeps it so: drop the NOT NULL and this + * fails, which is the warning that the guard's predicate has to grow an `IS NULL` arm. + */ + test("a row with no provider is refused by NOT NULL, so no row hides from the count", async () => { + expect( + await outcome(() => + database.insert(brokeredConnections).values({ + provider: null as unknown as "arcade", + app, + holder: "deployment", + vendorUserId: "v", + }), + ), + ).toMatch(/null value in column "provider"/); + }); }); diff --git a/server/tests/provider-selection.test.ts b/server/tests/provider-selection.test.ts index a4e19b941..9b65127bb 100644 --- a/server/tests/provider-selection.test.ts +++ b/server/tests/provider-selection.test.ts @@ -71,6 +71,34 @@ describe("assertActivatable", () => { expect(() => assertActivatable("composio", 0)).not.toThrow(); }); + /** + * A count that is not a count refuses, and never reaches the sentence a person reads. + * + * `NaN <= 0` is false, so before this was guarded a NaN sailed past the "nothing to refuse" test + * and landed in the refusal as the literal words "NaN brokered connections". The refusal must say + * that the count could not be taken, which is the true thing, and must never print the sentinel. + */ + test("a count that is not a whole number of rows refuses, without printing itself", () => { + for (const unusable of [ + Number.NaN, + Number.POSITIVE_INFINITY, + -1, + 1.5, + ] as const) { + let caught: unknown; + try { + assertActivatable("arcade", unusable); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(BrokerRefusalError); + const sentence = brokerSentence(caught) ?? ""; + expect(sentence).toContain("could not be counted"); + expect(sentence).not.toContain(String(unusable)); + expect(sentence).not.toContain("brokered connections still belong"); + } + }); + test("one row for the other provider refuses, in the other direction and in the singular", () => { let caught: unknown; try { From 3a8554bf3e3c7c33190ff9667577a839868aead0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:43:27 +0200 Subject: [PATCH 121/232] fix(plugins): refuse a provider configuration this deployment cannot decide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `selectProviderWiring` returned null wiring — no broker, no transport seam, no word to anybody — for both keys set with `OPENBOT_BROKER_PROVIDER` unset. Nobody wants that state on purpose: the deployment comes up holding two vendor keys and brokering nothing, so every brokered feature is dead and the symptom surfaces much later and far away. It is now refused at boot, in the shape of the refusal already beside it, naming the two keys and the variable and never a value. Same-pattern audit of the whole file: the two vendor branches each carried `&& c.ApiKey !== undefined`, a narrowing that doubled as a silent exit to the same null wiring. The narrowing is kept as `requiredKey`, which throws instead of falling through; it cannot fire today, because `activeProvider` selects only from a key it has seen, but if the two ever disagree the deployment says so rather than unwiring itself. The one remaining null wiring is returned from the branch that means it — no keys at all — and is no longer reachable by falling through. Symbols: `requiredKey` added, module-private, called twice, both in this file. `selectProviderWiring` keeps its name, signature and return type; its three call sites (`server/src/index.ts:792`, and `server/tests/provider-selection.test.ts`) are unchanged in shape. `activeProvider` is untouched: it is a query, and the refusal belongs to the wiring. `docs/plugins/arcade.md` said "with both set and the variable unset, neither is active" and listed two refusals; it now states the third and quotes its sentence. Not changed: `provider-wiring.ts:61` building a client from an empty-string key, which is a separate finding owned elsewhere. The `!== undefined` semantics are preserved exactly here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- docs/plugins/arcade.md | 6 +- server/src/index.ts | 3 +- server/src/plugins/provider-wiring.ts | 89 ++++++++++++++++++------- server/tests/provider-selection.test.ts | 34 +++++++--- 4 files changed, 98 insertions(+), 34 deletions(-) diff --git a/docs/plugins/arcade.md b/docs/plugins/arcade.md index 69ddc7968..04846a036 100644 --- a/docs/plugins/arcade.md +++ b/docs/plugins/arcade.md @@ -37,11 +37,13 @@ anything else stops the server at startup. **Where exactly one of the two keys is set and the variable is unset, that provider is the active one, so no existing deployment has to set anything.** The variable matters only when both keys are set. Setting both is legal and expected, because ending an account at the old vendor needs that -vendor's key. With both set and the variable unset, neither is active. +vendor's key. With both set, the variable has to say which one is live: both set and the variable +unset stops the server at startup rather than coming up with no broker at all. -Two refusals protect this, and both are deliberate: +Three refusals protect this, and all three are deliberate: - **A named provider whose key is not set stops the server at startup.** An operator sees: _OPENBOT_BROKER_PROVIDER=arcade names a provider whose ARCADE_API_KEY is not set. Set ARCADE_API_KEY, or change or unset OPENBOT_BROKER_PROVIDER._ It does not fall back to a deployment with no broker, because that would send somebody looking for a broken vendor instead of at their own environment. +- **Both keys set with the variable unset stops the server at startup.** An operator sees: _COMPOSIO_API_KEY and ARCADE_API_KEY are both set and OPENBOT_BROKER_PROVIDER is unset, so no provider is active and nothing brokered would work. Set OPENBOT_BROKER_PROVIDER to composio or arcade to say which vendor is live, or unset the key of the vendor this deployment is not using._ Nobody wants that state on purpose — the deployment would hold two vendor keys and broker nothing — and wiring no broker silently would surface much later and far away, as brokered features that simply do not work. - **Activating a provider is refused while the other one still holds connections.** It is keyed on connection rows, not on keys. The message names the provider being activated, how many connections still belong to the other, and tells the operator to keep both keys set and end those connections through the other provider's sunset flow first. Switching by editing the environment would leave accounts nobody can revoke. ## Setting up the Arcade project diff --git a/server/src/index.ts b/server/src/index.ts index 2d42db28c..12a37614e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -787,7 +787,8 @@ const sandboxedStore = createSandboxedStore(database, bootAuditStore); * EXACTLY ONE PROVIDER, DECIDED ONCE. `selectProviderWiring` answers which transport client is * installed and which broker goes on to the store and the routes; the other client is installed as * an explicit `null`, so a deployment holding both keys (legal, and needed to end accounts at the - * old vendor) never has two seams live. It refuses a named provider whose key is unset. + * old vendor) never has two seams live. It refuses, rather than wiring nothing, both configurations + * it cannot decide: a named provider whose key is unset, and both keys set with nothing named. */ const providerWiring = selectProviderWiring(config, { composio: createComposioClient, diff --git a/server/src/plugins/provider-wiring.ts b/server/src/plugins/provider-wiring.ts index 971d9bd4f..8ac214d35 100644 --- a/server/src/plugins/provider-wiring.ts +++ b/server/src/plugins/provider-wiring.ts @@ -24,11 +24,23 @@ export type ProviderWiring = { /** * Chooses ONE provider from configuration and builds only that provider's client. * - * `activeProvider` answers `null` in three situations: no keys, both keys with nothing named, and - * a named provider whose key is unset. The first two are legal states. The third is an operator - * typo, and degrading it to a deployment with no broker would send them looking for a broken - * vendor instead of at their own environment, so it is refused here, naming the variables and - * never a value. + * `activeProvider` answers `null` in three situations, and only ONE of them is a deployment this + * function may wire to nothing: no keys at all. That one is unambiguous — nobody configured a + * broker, so having none is the answer the configuration asked for, and every brokered surface + * already says the connector is not configured here. + * + * THE OTHER TWO ARE AMBIGUOUS CONFIGURATION, AND AMBIGUITY IS REFUSED RATHER THAN DEGRADED. A + * named provider whose key is unset is an operator typo. Both keys set with nothing named is an + * operator half-way through a sunset who has not said which vendor is live — and it is not a state + * anybody wants on purpose, because the deployment comes up holding two vendor keys and brokering + * nothing, so every brokered feature is dead. Returning null wiring for either would be a failure + * nobody is told about at boot: the symptom surfaces much later and far away, as features that + * simply do not work, and sends an operator looking for a broken vendor instead of at their own + * environment. Both are therefore refused here, naming the variables and never a value. + * + * NO BRANCH BELOW REACHES NULL WIRING BY FALLING THROUGH. The one null wiring is returned from the + * branch that means it, and a selected provider whose key is somehow absent throws rather than + * quietly becoming the same thing — see the invariant below. */ export function selectProviderWiring( c: { @@ -42,32 +54,63 @@ export function selectProviderWiring( }, ): ProviderWiring { const provider = activeProvider(c); - if (provider === null && c.brokerProvider !== undefined) { - const keyName = - c.brokerProvider === "arcade" ? "ARCADE_API_KEY" : "COMPOSIO_API_KEY"; - throw new Error( - `OPENBOT_BROKER_PROVIDER=${c.brokerProvider} names a provider whose ${keyName} is not set. Set ${keyName}, or change or unset OPENBOT_BROKER_PROVIDER.`, - ); - } - if (provider === "arcade" && c.arcadeApiKey !== undefined) { - const client = create.arcade(c.arcadeApiKey); + if (provider === null) { + if (c.brokerProvider !== undefined) { + const keyName = + c.brokerProvider === "arcade" ? "ARCADE_API_KEY" : "COMPOSIO_API_KEY"; + throw new Error( + `OPENBOT_BROKER_PROVIDER=${c.brokerProvider} names a provider whose ${keyName} is not set. Set ${keyName}, or change or unset OPENBOT_BROKER_PROVIDER.`, + ); + } + if (c.composioApiKey !== undefined && c.arcadeApiKey !== undefined) { + throw new Error( + "COMPOSIO_API_KEY and ARCADE_API_KEY are both set and OPENBOT_BROKER_PROVIDER is unset, so no provider is active and nothing brokered would work. Set OPENBOT_BROKER_PROVIDER to composio or arcade to say which vendor is live, or unset the key of the vendor this deployment is not using.", + ); + } + // Neither key set: a deployment that asked for no broker, and gets exactly that. return { - provider, - broker: client.broker, - install: { composio: null, arcade: client.actions }, + provider: null, + broker: null, + install: { composio: null, arcade: null }, }; } - if (provider === "composio" && c.composioApiKey !== undefined) { - const client = create.composio(c.composioApiKey); + if (provider === "arcade") { + const client = create.arcade( + requiredKey(provider, c.arcadeApiKey, "ARCADE_API_KEY"), + ); return { provider, broker: client.broker, - install: { composio: client.actions, arcade: null }, + install: { composio: null, arcade: client.actions }, }; } + const client = create.composio( + requiredKey(provider, c.composioApiKey, "COMPOSIO_API_KEY"), + ); return { - provider: null, - broker: null, - install: { composio: null, arcade: null }, + provider, + broker: client.broker, + install: { composio: client.actions, arcade: null }, }; } + +/** + * The key a selected provider must have, demanded rather than tested. + * + * {@link activeProvider} selects a provider only from a key it has seen, so this cannot fire today. + * It is written as a demand because of what the alternative WAS: the two branches above each + * carried `&& c.ApiKey !== undefined`, which made a selected provider with no key fall + * through to null wiring — the broker silently unwired, by the very code whose job is to wire it. + * A narrowing that doubles as a silent exit is the shape this finding is about, so the narrowing is + * kept and the exit is removed: if the two ever disagree, the deployment says so at boot. + */ +function requiredKey( + provider: BrokerProvider, + apiKey: string | undefined, + keyName: string, +): string { + if (apiKey !== undefined) return apiKey; + throw new Error( + `${provider} was selected as the broker but ${keyName} is not set. This is a bug in provider selection, not a configuration error; no broker was wired.`, + ); +} diff --git a/server/tests/provider-selection.test.ts b/server/tests/provider-selection.test.ts index a4e19b941..153b21b00 100644 --- a/server/tests/provider-selection.test.ts +++ b/server/tests/provider-selection.test.ts @@ -137,19 +137,37 @@ describe("selectProviderWiring", () => { expect(f.calls).toEqual(["composio:1"]); }); - test("both keys with nothing named installs neither and builds neither", () => { + test("both keys with nothing named is refused, not degraded to no broker", () => { const f = factories(); - const wiring = selectProviderWiring( - { composioApiKey: "c", arcadeApiKey: "a" }, - f.create, + expect(() => + selectProviderWiring( + { composioApiKey: "c", arcadeApiKey: "a" }, + f.create, + ), + ).toThrow( + /COMPOSIO_API_KEY.*ARCADE_API_KEY.*OPENBOT_BROKER_PROVIDER|OPENBOT_BROKER_PROVIDER.*COMPOSIO_API_KEY.*ARCADE_API_KEY/s, ); - expect(wiring.provider).toBeNull(); - expect(wiring.install.composio).toBeNull(); - expect(wiring.install.arcade).toBeNull(); - expect(wiring.broker).toBeNull(); expect(f.calls).toEqual([]); }); + test("the refusal for two keys names neither key's value", () => { + const f = factories(); + let caught: unknown; + try { + selectProviderWiring( + { composioApiKey: "composio-secret", arcadeApiKey: "arcade-secret" }, + f.create, + ); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + const sentence = (caught as Error).message; + expect(sentence).not.toContain("composio-secret"); + expect(sentence).not.toContain("arcade-secret"); + expect(sentence).toContain("OPENBOT_BROKER_PROVIDER"); + }); + test("both keys with a provider named installs only the named one", () => { const f = factories(); const wiring = selectProviderWiring( From 9a17c14ae8a9f228ec0625abb1a3882aab46f688 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:44:22 +0200 Subject: [PATCH 122/232] test(arcade): search a logged value for a credential instead of flattening it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D31 gate exists to prove that a bearer URL and an access token reach no model, log line, error body, audit row or redirect. It read each captured console argument through `String(arg)`, which renders an object as "[object Object]" — so a token logged INSIDE an object passed it, and the status reply the case feeds through carries one in `context: { token }`. The gate could not see the very shape it constructs. Every value is now WALKED by `harvest`: array and set members, map keys and values, own property names and their values, an error's message, name, stack and cause, and the rendering of anything with a `toString` of its own. The audit and connection rows go through it too, since `JSON.stringify` is blind to a Map, a Set and an Error's message. The answer is checked WHOLE — every header and the body — rather than `Location` alone, in the D31 case and in the vendor-unavailable case whose error message carries the token. Watched against a planted leak: `console.log("...", { context: answer })` in `settledStatus` passed the old gate and fails the new one, naming the token. No real leak: the route is clean on all four surfaces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/tests/arcade-verify.test.ts | 107 ++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index fcf2f3643..1ccf0eac9 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -66,6 +66,8 @@ const MINTED = "ar_minted_for_this_attempt"; const APP_URL = "https://app.example"; /** A stand-in for the live `gho_…` the vendor's own status reply carries beside the three fields. */ const TOKEN = `gho_live_access_token_${suite}`; +/** The profile address the same reply carries, which is no more the route's to repeat. */ +const EMAIL = "person@example.test"; /** Every call this suite lets reach the seam. A refusal that spends one is a refusal too late. */ let confirmCalls: { handle: string; vendorUserId: string }[] = []; @@ -260,6 +262,79 @@ async function expectNothingWritten() { }).toEqual({ connections: 0, successEvents: 0 }); } +/** + * EVERY STRING A VALUE CARRIES, however deeply it is buried, and not `String(value)`. + * + * A console argument is not a string, an audit row is not a string, and a response is not a string. + * `String({ token })` is `"[object Object]"`, so a confinement check that flattened each argument + * that way would read a leaked credential as six harmless characters — and the vendor status reply + * the D31 case feeds through carries its token inside `context: { token }`, which is exactly the + * shape that flattening hides. `JSON.stringify` is better but still blind to a `Map`, a `Set`, an + * `Error`'s message, a key that IS the secret, and anything that renders itself through its own + * `toString`. So the value is WALKED: array and set members, map keys and values, own property + * names and their values, an error's message, name, stack and cause, and the rendering of any + * object that renders as something other than its default tag. + */ +function harvest(value: unknown, into: string[], seen: Set): void { + if (value === null || value === undefined) return; + if (typeof value === "string") { + into.push(value); + return; + } + if (typeof value !== "object" && typeof value !== "function") { + into.push(String(value)); + return; + } + const object = value as object; + if (seen.has(object)) return; + seen.add(object); + // Anything with a `toString` of its own: an Error, a URL, a Buffer, a class that renders itself. + try { + const rendered = String(object); + if (rendered !== Object.prototype.toString.call(object)) + into.push(rendered); + } catch { + // A value that refuses to render still has its properties walked below. + } + if (value instanceof Error) { + into.push(value.name, value.message, value.stack ?? ""); + harvest((value as { cause?: unknown }).cause, into, seen); + } + if (Array.isArray(value) || value instanceof Set) { + for (const item of value) harvest(item, into, seen); + } + if (value instanceof Map) { + for (const [key, item] of value) { + harvest(key, into, seen); + harvest(item, into, seen); + } + } + for (const key of Reflect.ownKeys(object)) { + if (typeof key === "string") into.push(key); + try { + harvest((object as Record)[key], into, seen); + } catch { + // A throwing getter hides nothing the other properties do not already show. + } + } +} + +/** + * Where a secret shows up inside a value, as the strings that carry it — EMPTY when it is confined. + * Returned rather than asserted so a failure names the leaking string instead of saying `false`. + */ +function mentionsOf(value: unknown, secret: string): string[] { + const found: string[] = []; + harvest(value, found, new Set()); + return found.filter((text) => text.includes(secret)); +} + +/** A whole answer as the browser would receive it: every header, and the body, never one field. */ +async function asDelivered(response: Response) { + const copy = response.clone(); + return { headers: [...copy.headers], body: await copy.text() }; +} + /** The attempt row itself, which is what every "nothing was written" case is really about. */ async function attemptRow(actorId = ACTOR) { const [row] = await database @@ -698,7 +773,9 @@ describe("what the authorization's own status decides", () => { const response = await verify(ACTOR, "?flow_id=flow-abc"); await expectRefusal(response, "vendor-unavailable"); - expect(response.headers.get("location")).not.toContain(TOKEN); + // THE WHOLE ANSWER: the vendor's failure carries the token in its message, so a check of + // `Location` alone would miss it riding another header or the body. + expect(mentionsOf(await asDelivered(response), TOKEN)).toEqual([]); const row = await attemptRow(); expect(row?.completedAt).toBeNull(); @@ -1009,13 +1086,16 @@ describe("a return leg that completes", () => { scopes: ["gmail.read"], token: TOKEN, context: { token: TOKEN }, - user_info: { email: "person@example.test" }, + user_info: { email: EMAIL }, }) as never; - const said: string[] = []; + /** + * THE ARGUMENTS THEMSELVES, not a rendering of them: a token logged inside an object is the + * case this test constructs, and it is the case any flattening would hide. See `harvest`. + */ + const said: unknown[][] = []; const before = globalThis.console; - const hear = (...args: unknown[]) => - said.push(args.map((arg) => String(arg)).join(" ")); + const hear = (...args: unknown[]) => said.push(args); globalThis.console = { ...before, log: hear, @@ -1032,13 +1112,20 @@ describe("a return leg that completes", () => { } expect(response.status).toBe(303); - expect(said.join("\n")).not.toContain(TOKEN); - expect(response.headers.get("location")).not.toContain(TOKEN); + expect(mentionsOf(said, TOKEN)).toEqual([]); + expect(mentionsOf(said, EMAIL)).toEqual([]); + + // THE WHOLE ANSWER, because a credential that misses `Location` can still ride another header + // or the body the browser is handed on the way to it. + const delivered = await asDelivered(response); + expect(mentionsOf(delivered, TOKEN)).toEqual([]); + expect(mentionsOf(delivered, EMAIL)).toEqual([]); const { connections, events } = await written(); - expect(JSON.stringify(events)).not.toContain(TOKEN); - expect(JSON.stringify(events)).not.toContain("person@example.test"); - expect(JSON.stringify(connections)).not.toContain(TOKEN); + expect(mentionsOf(events, TOKEN)).toEqual([]); + expect(mentionsOf(events, EMAIL)).toEqual([]); + expect(mentionsOf(connections, TOKEN)).toEqual([]); + expect(mentionsOf(connections, EMAIL)).toEqual([]); }); }); From 4dc77e3d18e79c6a04af531d92bb1817c43a769f Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:44:52 +0200 Subject: [PATCH 123/232] test(plugins): spell the vendor column as production does and prove both directions of the cross-provider guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two store provider fixtures modelled a deployment that cannot exist. Both wrote `vendor: "arcade"` / `vendor: "composio"` — the lower-case provider key, which is what the PROVENANCE column holds. Production mints the vendor column from `BROKERED_PROFILE[provider].vendor`, which is `"Arcade"` / `"Composio"`. Both files now take that word from `BROKERED_PROFILE` rather than spelling a second, wrong one; nothing on these paths reads the column, so this buys fidelity rather than coverage, and it is recorded as such in the report. The guards themselves were each asserted in one direction only: * `store-toolkit-dispatch.test.ts` proved an `arcade://` row answers nothing under a composio store, and nothing at all about a `composio://` row under an arcade store. The dispatch is a per-provider table, so one entry proved is one entry proved. * `store-provider-required.test.ts` proved a composio store does not read an arcade connection filed under the same app name, and not the mirror. A test for each missing direction is added. No symbol added, renamed, resignatured or removed: both files gain a value import of `BROKERED_PROFILE` from `../src/plugins/access` (already exported and already imported by `store-scheme-sites.test.ts`), and two `test()` cases. No call site anywhere changes. Break-the-guard evidence (source reverted after each): * `toolkitOfFor` made to try both parsers — the new dispatch mirror fails with `Received: { toolkit: "gmail", accounts: [] }` where null is required. * the `provider` predicate dropped from `accountsOn`'s connection read — the new provider-required mirror fails with the foreign `other-*` account appearing in the slice. The `@ts-expect-error` pin at `store-provider-required.test.ts:59` was re-verified after the edits: with `brokerProvider` made optional in `PluginStoreOptions`, `tsc` reports `tests/store-provider-required.test.ts(59,1): error TS2578: Unused '@ts-expect-error' directive.` — it still errors for the intended reason, and the probe was reverted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/tests/store-provider-required.test.ts | 66 +++++++++++++++++++- server/tests/store-toolkit-dispatch.test.ts | 17 ++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/server/tests/store-provider-required.test.ts b/server/tests/store-provider-required.test.ts index cc0af2af0..b3a147f61 100644 --- a/server/tests/store-provider-required.test.ts +++ b/server/tests/store-provider-required.test.ts @@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import { createDatabase } from "../src/db/client"; import { brokeredConnections, mcpServers } from "../src/db/schema"; -import type { BrokerProvider } from "../src/plugins/access"; +import { BROKERED_PROFILE, type BrokerProvider } from "../src/plugins/access"; import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; @@ -66,7 +66,7 @@ describe("the store reads and writes its own provider's slice", () => { await database.insert(mcpServers).values({ id, title: "Example", - vendor: "arcade", + vendor: BROKERED_PROFILE.arcade.vendor, url: `arcade://${app}`, provenance: "arcade", authScheme: "OAUTH2", @@ -112,7 +112,7 @@ describe("the store reads and writes its own provider's slice", () => { await database.insert(mcpServers).values({ id, title: "Example", - vendor: "composio", + vendor: BROKERED_PROFILE.composio.vendor, url: `composio://${app}`, provenance: "composio", authScheme: "OAUTH2", @@ -161,4 +161,64 @@ describe("the store reads and writes its own provider's slice", () => { }, ]); }); + /* + * THE MIRROR OF THE TEST ABOVE, and it is here because half a cross-provider guard is not one. + * The slice is keyed on the store's own provider in both directions, so a composio row sitting + * under an arcade store's app must be as invisible as the arcade row is to a composio store. + */ + test("an arcade store writes arcade rows and does not see the composio ones", async () => { + const app = `Notion${suite}`; + const id = `required-${suite}-${randomUUID().slice(0, 6)}`; + await database.insert(mcpServers).values({ + id, + title: "Example", + vendor: BROKERED_PROFILE.arcade.vendor, + url: `arcade://${app}`, + provenance: "arcade", + authScheme: "OAUTH2", + }); + serverIds.push(id); + apps.push(app); + + // A composio row already filed under the same app name, which an arcade store must not read. + await database.insert(brokeredConnections).values({ + provider: "composio", + app, + holder: "person", + userId: `other-${suite}`, + vendorUserId: `othervendor-${suite}`, + }); + + await storeFor("arcade").recordBrokeredConnection({ + toolkit: app, + account: { + holder: "person", + userId: `user-${suite}`, + vendorUserId: `vendor-${suite}`, + }, + verified: true, + probeAction: null, + }); + + const written = await database + .select({ userId: brokeredConnections.userId }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.app, app), + eq(brokeredConnections.provider, "arcade"), + ), + ); + expect(written).toHaveLength(1); + expect(written[0]?.userId).toBe(`user-${suite}`); + + const seen = await storeFor("arcade").accountsOn(id); + expect(seen?.accounts).toEqual([ + { + holder: "person", + userId: `user-${suite}`, + vendorUserId: `vendor-${suite}`, + }, + ]); + }); }); diff --git a/server/tests/store-toolkit-dispatch.test.ts b/server/tests/store-toolkit-dispatch.test.ts index ad37ef1c6..d4cf1b7f3 100644 --- a/server/tests/store-toolkit-dispatch.test.ts +++ b/server/tests/store-toolkit-dispatch.test.ts @@ -4,7 +4,7 @@ import { eq } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import { createDatabase } from "../src/db/client"; import { brokeredConnections, mcpServers } from "../src/db/schema"; -import type { BrokerProvider } from "../src/plugins/access"; +import { BROKERED_PROFILE, type BrokerProvider } from "../src/plugins/access"; import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; @@ -51,7 +51,9 @@ async function seed(provenance: BrokerProvider, url: string, app: string) { await database.insert(mcpServers).values({ id, title: "Example", - vendor: provenance, + // What production mints: the broker's label out of `BROKERED_PROFILE`, never the lower-case + // provider key. The provenance column is the provider key; the vendor column never is. + vendor: BROKERED_PROFILE[provenance].vendor, url, provenance, authScheme: "OAUTH2", @@ -87,4 +89,15 @@ describe("the toolkit a row names is read by the store's own provider", () => { expect(answer?.toolkit).toBe("gmail"); expect(answer?.accounts).toHaveLength(1); }); + + /* + * THE MIRROR OF THE REFUSAL ABOVE. The dispatch is a table with an entry per provider, so a guard + * proved in one direction only is a guard proved for one entry only: an arcade store reading a + * `composio://` row has to answer nothing for the same reason, and nothing about the first case + * says the second one holds. + */ + test("a composio://gmail row under arcade names no app and is refused, not mis-parsed", async () => { + const id = await seed("composio", `composio://gmail`, `gmail`); + expect(await storeFor("arcade").accountsOn(id)).toBeNull(); + }); }); From 437a9ecefe8947203e08737761c40cbbe65b1c1f Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:44:53 +0200 Subject: [PATCH 124/232] fix(arcade): name an authorization attempt by an instant, not by a microsecond MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pending_authorizations.created_at` is a `timestamptz`, which PostgreSQL keeps to the microsecond; the `Date` the driver hands a caller back carries milliseconds. A row stored at `…13.407577` reads back as `…13.407Z`, and `recordAuthorizationMint` and `discardAuthorizationAttempt` matched it with an exact `=` against that `Date`, which matched zero rows. `claimAuthorizationAttempt` works around this by writing `date_trunc('milliseconds', now())`, but the COLUMN DEFAULT is `defaultNow()` at full precision — what drizzle's inferred insert type invites any other writer to take — so a row written that way could never be matched, no `auth_id` was ever written, and per the table's own docblock "an attempt holding null can never complete": a silently dead authorization. The same two call sites also raised `TypeError: value.toISOString is not a function` from inside `PgTimestamp.mapToDriverValue` when handed a `createdAt` that had been through JSON — typed `Date`, carrying a string. Both now go through `attemptNamed`, which truncates the COLUMN in the comparison as well as the value, so a row is reachable however it was written, and through `attemptInstant`, which accepts an ISO string and refuses anything naming no instant with a message that says which field was wrong instead of a driver `TypeError`. Fixed in the store rather than in the schema deliberately: `.default(sql\`date_trunc(...)\`)` would change generated DDL and need a migration on a branch whose migration state is delicate, and it would still only hold for rows written AFTER it — the matcher holds for every row, whatever wrote it. Truncating cannot match the wrong row: `actor_id` is the table's primary key, so there is at most one attempt per actor and `created_at` is the staleness guard on that one row, not a selector among several; a value naming an older attempt is still refused, which is what stops a late discard deleting the replacement. Signature change: `recordAuthorizationMint` and `discardAuthorizationAttempt` widen `createdAt` from `Date` to `Date | string`. Both are called only from `server/src/plugins/routes.ts` (lines 2628, 2647, 2683), all three passing a `Date`, and from `server/tests/arcade-attempt-store.integration.test.ts` and `server/tests/arcade-completion-transaction.integration.test.ts`. A widened input breaks no caller. New non-exported helpers `attemptInstant` and `attemptNamed` have no other call sites. Audit: the only timestamp column in `server/src/db/schema/plugins.ts` used as an equality key anywhere is `pendingAuthorizations.createdAt`; every other one (`updatedAt`, `connectedAt`, `verifiedAt`, `toolsRefreshedAt`, `publishedAt`, `grantedAt`, `completedAt`) is written, ordered by or range-compared, never matched exactly. No DDL change, so no migration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/store.ts | 87 +++++++++++++++---- .../arcade-attempt-store.integration.test.ts | 84 ++++++++++++++++++ 2 files changed, 154 insertions(+), 17 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index d075e1415..181927265 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1316,6 +1316,64 @@ function pendingAuthorizationFromRow( }; } +/** + * The instant a caller names an authorization attempt by, as a `Date`, or a refusal naming the + * field. + * + * THE DECLARED TYPE IS `Date` AND THE VALUE IS NOT ALWAYS ONE. `PendingAuthorization.createdAt` is + * typed `Date` and is one while it stays in this process, but an attempt that has been through + * JSON — serialised into a job, a cache or a cross-process hop and parsed back — carries an ISO + * STRING under a `Date` annotation. Handed to drizzle, that string reaches + * `PgTimestamp.mapToDriverValue`, which calls `.toISOString()` on it and raises + * `TypeError: value.toISOString is not a function` from inside the driver: a failure that names + * neither the field nor the caller. An ISO string names the same instant perfectly well, so it is + * accepted; anything that names NO instant is refused HERE, where the message can say what was + * wrong, rather than running as a query that silently matches nothing. + */ +function attemptInstant(createdAt: Date | string): Date { + const instant = + createdAt instanceof Date + ? createdAt + : typeof createdAt === "string" + ? new Date(createdAt) + : new Date(Number.NaN); + if (Number.isNaN(instant.getTime())) { + throw new Error( + `An authorization attempt's created_at must be a Date or an ISO timestamp, not ${JSON.stringify(createdAt) ?? String(createdAt)}.`, + ); + } + return instant; +} + +/** + * The condition that names ONE actor's attempt by the `created_at` a caller holds. + * + * MATCHED AT MILLISECOND PRECISION ON BOTH SIDES, BECAUSE THE TWO SIDES DO NOT CARRY THE SAME ONE. + * `created_at` is a `timestamptz`, which PostgreSQL keeps to the MICROSECOND, and the `Date` the + * driver hands back carries MILLISECONDS. A row stored at `…13.407577` is read back as + * `…13.407Z`, and an exact `=` against that `Date` matches NOTHING — so the mint writes no + * `auth_id`, and per {@link pendingAuthorizations}, "an attempt holding null can never complete": + * a silently dead attempt that waits out its fifteen minutes. + * + * {@link createPluginStore}'s `claimAuthorizationAttempt` writes `date_trunc('milliseconds', now())` + * for exactly this reason, but THAT ONLY HOLDS FOR THE ONE WRITER THAT REMEMBERS. The column's + * default is `now()` at full precision, which is what drizzle's inferred insert type invites any + * other writer — a fixture, a backfill, a future route — to take. Truncating the COLUMN in the + * comparison holds whatever wrote the row, which is why the invariant lives here rather than in + * the schema: it needs no migration and no writer's cooperation. + * + * TRUNCATING CAN NEVER MATCH A DIFFERENT ROW: `actor_id` is the table's PRIMARY KEY, so there is at + * most one attempt per actor and `created_at` is the staleness guard on that one row, never a + * selector among several. It still refuses a value naming an OLDER attempt, which is what stops a + * late discard from deleting the replacement that took the key. + */ +function attemptNamed(actorId: string, createdAt: Date | string) { + return and( + eq(pendingAuthorizations.actorId, actorId), + sql`date_trunc('milliseconds', ${pendingAuthorizations.createdAt}) = ${attemptInstant(createdAt).toISOString()}::timestamptz`, + ); +} + /** * The literal recorded on the row, which is the scheme a later call must keep using. * @@ -6286,8 +6344,10 @@ export function createPluginStore(options: PluginStoreOptions) { * snapshot is live but not yet visible to it, and the caller's 409 then names nothing. * * `created_at` is truncated to milliseconds so the `Date` handed back names the stored value - * exactly. {@link recordAuthorizationMint} and {@link discardAuthorizationAttempt} match on it, - * and a microsecond the `Date` cannot carry would match nothing. + * exactly — a microsecond no `Date` can carry would otherwise sit in the row unreachable. + * {@link recordAuthorizationMint} and {@link discardAuthorizationAttempt} do not RELY on that: + * they truncate the column in the comparison too, so a row written by anything else — the + * column's own `defaultNow()`, say — is still reachable. See {@link attemptNamed}. */ async claimAuthorizationAttempt(input: { actorId: string; @@ -6336,24 +6396,21 @@ export function createPluginStore(options: PluginStoreOptions) { }, /** - * Write the `ar_...` the vendor minted onto the attempt, matched on `(actor_id, created_at)`. + * Write the `ar_...` the vendor minted onto the attempt, matched on `(actor_id, created_at)` + * through {@link attemptNamed}, which is where the precision of that match is argued. * Answers whether it wrote: false is an attempt that was discarded or replaced meanwhile. */ async recordAuthorizationMint(input: { actorId: string; - createdAt: Date; + /** A `Date`, or the ISO string one becomes through JSON; see {@link attemptInstant}. */ + createdAt: Date | string; /** Null when the vendor answered with no id: the attempt then can never complete. */ authId: string | null; }): Promise { const written = await database .update(pendingAuthorizations) .set({ authId: input.authId }) - .where( - and( - eq(pendingAuthorizations.actorId, input.actorId), - eq(pendingAuthorizations.createdAt, input.createdAt), - ), - ) + .where(attemptNamed(input.actorId, input.createdAt)) .returning({ actorId: pendingAuthorizations.actorId }); return written.length > 0; }, @@ -6365,16 +6422,12 @@ export function createPluginStore(options: PluginStoreOptions) { */ async discardAuthorizationAttempt(input: { actorId: string; - createdAt: Date; + /** A `Date`, or the ISO string one becomes through JSON; see {@link attemptInstant}. */ + createdAt: Date | string; }): Promise { const deleted = await database .delete(pendingAuthorizations) - .where( - and( - eq(pendingAuthorizations.actorId, input.actorId), - eq(pendingAuthorizations.createdAt, input.createdAt), - ), - ) + .where(attemptNamed(input.actorId, input.createdAt)) .returning({ actorId: pendingAuthorizations.actorId }); return deleted.length > 0; }, diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index f6a0ce1e4..588ba88ea 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -265,3 +265,87 @@ describe("authorizationAttemptFor", () => { expect(await store.authorizationAttemptFor(a)).toBeNull(); }); }); + +/** + * Naming an attempt by its `created_at` has to work for a row ANY writer wrote, and for a value + * that has been through JSON on the way back. + * + * `claimAuthorizationAttempt` truncates what it writes to milliseconds, but the COLUMN DEFAULT is + * `now()`, which PostgreSQL keeps to the microsecond; a row that takes the default — which is what + * drizzle's inferred insert type invites — is read back as a millisecond `Date` naming a different + * instant than the one stored. An exact `=` then matches nothing and the attempt is silently dead: + * no `auth_id` is ever written, and per the table's own docblock "an attempt holding null can never + * complete". + */ +describe("naming an attempt by created_at", () => { + /** Takes the column default, the way any writer using the inferred insert type does. */ + const insertWithDefaultCreatedAt = async (actorId: string) => { + const [row] = await database + .insert(pendingAuthorizations) + .values({ + actorId, + serverId, + action: "connect", + holder: "person", + userId: actorId, + vendorUserId: `v-${actorId}`, + }) + .returning(); + if (!row) throw new Error("insert returned nothing"); + return row; + }; + + test("a mint matches a row written with the column default", async () => { + const a = actor("default-mint"); + const row = await insertWithDefaultCreatedAt(a); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt: row.createdAt, + authId: "ar_default", + }); + expect(wrote).toBe(true); + expect((await store.authorizationAttemptFor(a))?.authId).toBe("ar_default"); + }); + + test("a discard matches a row written with the column default", async () => { + const a = actor("default-discard"); + const row = await insertWithDefaultCreatedAt(a); + expect( + await store.discardAuthorizationAttempt({ + actorId: a, + createdAt: row.createdAt, + }), + ).toBe(true); + expect(await store.authorizationAttemptFor(a)).toBeNull(); + }); + + test("a created_at that has been through JSON still names its attempt", async () => { + const a = actor("json-mint"); + const got = await claim(a); + if (!got.claimed) throw new Error("claim failed"); + // The declared type says `Date`; a serialised attempt hands back a string. + const serialised: { createdAt: Date } = JSON.parse( + JSON.stringify(got.attempt), + ); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt: serialised.createdAt, + authId: "ar_json", + }); + expect(wrote).toBe(true); + expect((await store.authorizationAttemptFor(a))?.authId).toBe("ar_json"); + }); + + test("a created_at that names no instant is refused, not matched", async () => { + const a = actor("bad-instant"); + await claim(a); + await expect( + store.recordAuthorizationMint({ + actorId: a, + createdAt: new Date("not a date"), + authId: "ar_bad", + }), + ).rejects.toThrow(/created_at/); + expect((await store.authorizationAttemptFor(a))?.authId).toBeNull(); + }); +}); From 69a2cc9bb63cbbc6cb4677a05eced37a023991b3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:45:07 +0200 Subject: [PATCH 125/232] fix(arcade): read a string with no text in it as no text at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `textOf` claimed to answer "is this text" and answered yes for `""`. Every caller that chains it with `??` then ends on the empty field, because `??` falls through on null and undefined only: a connection row carrying `provider_user_info: { name: "", login: "maxim" }` yielded NO account name at all, and the Connected-accounts screen drew a blank where the identity goes on a connection that is genuinely connected. The emptiness belongs in `textOf`, not in a `||` at each call site — one of which would be forgotten. Whitespace-only is the same blank to whoever reads the screen, so it answers the same. The value itself is returned untouched: this says whether there is text, it does not reformat the vendor's. No symbol was added, renamed, resignatured or removed. `textOf` is module-local (never exported) and has ten call sites, all in this file; behaviour changes at three of them and is unchanged at the other seven: - CHANGED L692 `accountName`: `textOf(info?.name) ?? textOf(info?.login)` now reaches the login. The defect. - CHANGED L787 `authorizations.status`: an empty `status` reads `"unknown"` rather than `""`. routes.ts compares it to "completed"/"failed"/"pending", so the refusal it produces is the same; the value is now honest. - CHANGED L788 `authorizations.status`: an empty `user_id` reads `null` rather than `""`. The route's `settled.userId !== attempt.vendorUserId` mismatch verdict is unchanged either way. - unchanged L49/L50 `normalizeArcadeError`: `name`/`message` become undefined instead of `""`. `arcade.ts`'s `arcadeFailure` then answers null for such a thrown value, and `callTool` returns the same "could not run" sentence, because `"" !== "tool_authorization_required"` on both sides. - unchanged L329/L330 `authProviderInstances`: already guarded by `instance &&`, and `wanted` can never be empty (`authProviderOf` only adds truthy providers). - unchanged L417 `connectionsFor`: `instances` holds no empty id, so an empty `provider_id` matched nothing before and is skipped now. - unchanged L460 `grantStateOf` action, L469 secret key: both already `!x`. - unchanged L504/L505 token status/status: compared to literals, and `""` is falsy exactly where `undefined` was. Same-pattern audit of the whole file: `listApps` had the one other presence test that admitted an empty value — `typeof toolkit.name !== "string"` let a toolkit named `""` raise a blank, unconnectable row whose slug is what a URL is built from. It now skips, through the same `textOf`. The description beside it keeps its blank default, which is a label and not an identity. `authorize`'s `url` and id checks already spelled this rule inline and are left alone; scope arrays are vendor data echoed verbatim and are not a presence test. Red-green: with the fix reverted, the three new assertions fail — accountName answered `null` for `{ name: "", login: "maxim" }`, status answered `""`, and listApps answered `["Github", "", " "]`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 37 +++++++++++++---- server/tests/arcade-adapter-status.test.ts | 16 ++++++++ server/tests/arcade-adapter.test.ts | 46 ++++++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index b966b628d..ce54b4a86 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -19,8 +19,24 @@ import { /** Deeper than any real wrapping; a bound, so a cyclic `cause` cannot loop. */ const MAX_CAUSE_DEPTH = 16; +/** + * THE TEXT A VENDOR FIELD CARRIES, OR NOTHING — AND A STRING WITH NO TEXT IN IT IS NOTHING. + * + * `""` is absence wearing a value's clothes, and answering it as a value is what breaks every + * caller that then writes `textOf(a) ?? textOf(b)`: `??` falls through on null and undefined ONLY, + * so an empty `provider_user_info.name` SHADOWED a perfectly good `login` and the Connected-accounts + * screen drew a blank name on a connection that is genuinely connected. The emptiness belongs here, + * in the function that claims to answer "is this text", and not in a `||` at each call site — one + * of which would be forgotten. Whitespace-only is the same fact: a name of three spaces draws the + * same blank. + * + * The value is returned UNTOUCHED when it has text; this answers whether there is any, it does not + * reformat what the vendor sent. `authorize` below already spells this rule inline for `url` and + * the authorization id (`typeof x !== "string" || x === ""`), which is the same judgement made one + * field at a time. + */ const textOf = (value: unknown): string | undefined => - typeof value === "string" ? value : undefined; + typeof value === "string" && value.trim() !== "" ? value : undefined; /** * Walk the `cause` chain to the FIRST `APIError` and read its `status` and the response body's @@ -234,16 +250,21 @@ async function listApps(client: Arcade): Promise { const toolkit = ( item as { toolkit?: { name?: unknown; description?: unknown } } | null )?.toolkit; - if (!toolkit || typeof toolkit.name !== "string") continue; + // AN EMPTY NAME IS NO NAME, the same judgement {@link textOf} makes: the name is this app's + // SLUG as well as its label, so an action whose toolkit is named `""` would otherwise raise a + // blank, unconnectable row in the picker rather than being skipped as unreadable. + const name = textOf(toolkit?.name); + if (!name) continue; const classified = isClassified(record(item)?.metadata); const scopes = scopesOf(item); const standing = - counts.get(toolkit.name) ?? + counts.get(name) ?? ({ - slug: toolkit.name, - name: toolkit.name, - description: - typeof toolkit.description === "string" ? toolkit.description : "", + slug: name, + name, + // An absent description IS an empty one here: the field is a label with a blank default, + // not an identity, so nothing is shadowed by the two reading alike. + description: textOf(toolkit?.description) ?? "", logo: null, categories: [], actionCount: 0, @@ -260,7 +281,7 @@ async function listApps(client: Arcade): Promise { // vendor lists the same scopes in a different sequence. standing.permissions.add([...scopes].sort().join(" ")); } - counts.set(toolkit.name, standing); + counts.set(name, standing); } // `permissionCount` IS READ OFF THE FINISHED SET, never kept per page: a size taken mid-listing // is a count of the sets seen so far, which is a different number on every page but the last. diff --git a/server/tests/arcade-adapter-status.test.ts b/server/tests/arcade-adapter-status.test.ts index 209ebff21..0d7a88d1e 100644 --- a/server/tests/arcade-adapter-status.test.ts +++ b/server/tests/arcade-adapter-status.test.ts @@ -236,4 +236,20 @@ describe("authorizations.status narrows and drops the rest", () => { expect(narrowed.scopes).toEqual([]); expect(JSON.stringify(narrowed)).not.toContain(TOKEN); }); + + /** + * AN EMPTY STATUS IS NOT A STATUS AND AN EMPTY USER ID IS NOT AN ID. `??` falls through on null + * and undefined only, so a `textOf` answering `""` as text handed the return leg an empty status + * in place of `"unknown"` and an empty string where the identity comparison expects `null`. + */ + test("an empty status and user_id read as absent, not as empty text", async () => { + statusAnswers({ status: "", user_id: " ", scopes: [] }); + const { broker } = createArcadeClient("never-dialled"); + const flow = broker.authorizations; + if (!flow) throw new Error("Arcade must declare an authorization flow."); + + const narrowed = await flow.status("auth_123"); + expect(narrowed.status).toBe("unknown"); + expect(narrowed.userId).toBeNull(); + }); }); diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index 8a884962f..24cf556a7 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -214,6 +214,52 @@ describe("createArcadeClient", () => { rows.length = 0; rows.push({ provider_id: "cpk-poc", provider_user_info: {} }); expect(await ask()).toBeNull(); + + // AN EMPTY NAME MUST NOT SHADOW A GOOD LOGIN. `??` falls through on null and undefined only, + // so a `textOf` that answered `""` as text ended the chain on the empty field and drew a blank + // where the account's identity goes — on a connection that is genuinely connected. + rows.length = 0; + rows.push({ + provider_id: "cpk-poc", + provider_user_info: { name: "", login: "maxim" }, + }); + expect(await ask()).toBe("maxim"); + + // Whitespace-only is the same blank to whoever reads the screen. + rows.length = 0; + rows.push({ + provider_id: "cpk-poc", + provider_user_info: { name: " ", login: "maxim" }, + }); + expect(await ask()).toBe("maxim"); + + // And a row carrying nothing but empty text is no name at all, never an empty one. + rows.length = 0; + rows.push({ + provider_id: "cpk-poc", + provider_user_info: { name: "", login: "" }, + }); + expect(await ask()).toBeNull(); + }); + + test("a toolkit named with empty text raises no blank app", async () => { + stub(toolsProto(), "list", (...args: unknown[]) => { + const q = args[0] as { offset: number }; + return Promise.resolve({ + items: + q.offset === 0 + ? [ + toolOf("Github", 0), + { qualified_name: "X.A0", toolkit: { name: "" } }, + { qualified_name: "Y.A0", toolkit: { name: " " } }, + ] + : [], + offset: q.offset, + }); + }); + const apps = await createArcadeClient("never-dialled").broker.listApps(); + // The slug is what a URL and a picker row are built from; an empty one is unconnectable. + expect(apps.map((a) => a.slug)).toEqual(["Github"]); }); test("no app resolves to a fields connection", async () => { From c23644ca8dc1dc11ed83d22d404e2d08461bdc06 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:45:13 +0200 Subject: [PATCH 126/232] fix(arcade): read a listing's items off the body Arcade sent, not the page the SDK built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OffsetPage`'s constructor does `this.items = body.items || []`, so every reader of the SDK's PAGE OBJECT sees an array whatever the vendor sent. Three guards read items off the page and so could never fire for the case they were written for: - `pagedItems` — its `!Array.isArray(items)` refusal was unreachable, which made the `malformed:` sentence of all four of its callers (`listApps`, `grantsFor`, `authProviderInstances`, `connectionsFor`) dead text; a listing answering `{"error":"nope"}` resolved as an empty one. - `authProviderOf` — the one unpaged listing, same read, same dead sentence. - `createArcadeClient.listTools` — handed the transport the manufactured `[]`, so the transport's own guard (`arcade.ts`) was dead too and a refresh against a malformed listing would have cleared an app's recorded actions to none. C3 already taught `reportedTotalOf` to read `total_count` off the raw parsed body for the same reason (`body.total_count || 0` hides an absent total). That read is extracted here as `sentBody` and reused for items as `sentItemsOf`, keeping C3's fallback: an answer with no `body` is read directly, which is what `admin.authProviders.list` (a plain `APIPromise`, not a page) and every hand-built stub return. Audit of the same pattern: `OffsetPage` manufactures exactly `items`, `total_count` and `offset`; no other class in the SDK manufactures defaults. `total_count` was already handled; `offset` is never read off an answer in either file (both track their own). Everything else is read off row objects, which are plain parsed JSON. RED-GREEN: `server/tests/arcade-malformed-listing-engine.test.ts` serves `{"error":"nope"}` over a real socket through `ARCADE_BASE_URL` and lets the SDK build its own pages — 6 of its 7 tests failed before this change ("Received promise that resolved"), all 7 pass after. `arcade-pagination-engine.test.ts` stays green. The existing prototype-stubbing adapter tests are structurally blind to this class, which is why a test in that style is not used. Call sites (nothing renamed, removed or resignatured): - `sentBody` (new, private): called by `reportedTotalOf`, `sentItemsOf`. - `sentItemsOf` (new, private): called by `pagedItems`, `authProviderOf`, `listTools`. - `reportedTotalOf`: signature and behaviour unchanged; callers `pagedItems`, `listTools`. - `createArcadeClient`'s `listTools` return type unchanged, so `ArcadeActions` is untouched. Not changed: a JSON body of literal `null` still escapes as the vendor's own TypeError from inside `OffsetPage`'s constructor; catching it would mean wrapping the page call and swallowing genuine `APIError`s the authorize path depends on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 54 ++++-- .../arcade-malformed-listing-engine.test.ts | 170 ++++++++++++++++++ 2 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 server/tests/arcade-malformed-listing-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index b966b628d..42411bdcd 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -61,23 +61,42 @@ const CATALOGUE_PAGE_SIZE = 100; const CATALOGUE_MAX_PAGES = 1000; /** - * The `total_count` ARCADE ITSELF SENT, read off the RAW BODY and never off the SDK's page object. - * - * `OffsetPage`'s constructor does `this.total_count = body.total_count || 0`, so a listing that - * never sent the field presents a numeric `0` — and an ABSENT total becomes INDISTINGUISHABLE FROM - * A REPORTED ZERO. Every `typeof reported === "number"` test then passes on a field the vendor - * never sent, the cross-check becomes `seen < 0`, and a truncated listing commits as complete. - * `AbstractPage` keeps the parsed body untouched beside it, so the honest value is read from there; - * the page's own property is the fallback for an answer that has no `body` at all, which is what - * `admin.authProviders.list` (a plain `APIPromise`, not a page) and every hand-built stub return. + * WHAT ARCADE ITSELF SENT: the parsed response body, and never the SDK's page object. + * + * `OffsetPage`'s constructor MANUFACTURES DEFAULTS for the two fields every listing here reads — + * `this.items = body.items || []` and `this.total_count = body.total_count || 0` — so a field the + * vendor never sent presents on the page as a well-formed empty answer. Read off the page, "Arcade + * sent no items" is INDISTINGUISHABLE FROM "Arcade sent an empty list" and "Arcade sent no total" + * is INDISTINGUISHABLE FROM "Arcade said zero": the `Array.isArray` guards below can never fire, + * every `typeof reported === "number"` test passes on an absent field, and a malformed or truncated + * listing commits as a complete one. + * + * `AbstractPage` keeps the parsed body untouched beside it, so the honest values are read from + * there. The answer's own properties are the fallback for an answer that has NO `body` at all, + * which is what `admin.authProviders.list` (a plain `APIPromise`, not a page) and every hand-built + * stub return — those were never passed through a page constructor, so nothing was manufactured. */ -function reportedTotalOf(answer: unknown): number | undefined { +function sentBody(answer: unknown): Record | null { const page = record(answer); - if (!page) return undefined; - const raw = (record(page.body) ?? page).total_count; + if (!page) return null; + return record(page.body) ?? page; +} + +/** The `total_count` Arcade itself sent, or nothing. Read through {@link sentBody}, for its reason. */ +function reportedTotalOf(answer: unknown): number | undefined { + const raw = sentBody(answer)?.total_count; return typeof raw === "number" ? raw : undefined; } +/** + * The `items` Arcade itself sent, UNCHECKED — the caller's own guard is what decides whether this + * is a list. Read through {@link sentBody}, for its reason: off the page object, a listing that + * sent no `items` at all reads as an empty one and no guard anywhere can tell them apart. + */ +function sentItemsOf(answer: unknown): unknown { + return sentBody(answer)?.items; +} + /** * The HIGHEST total any page reported, never the LAST one. * @@ -126,7 +145,7 @@ async function* pagedItems(listing: Listing): AsyncGenerator { let ended = false; for (let index = 0; index < listing.maxPages; index += 1) { const answer = await listing.page(seen); - const items = record(answer)?.items; + const items = sentItemsOf(answer); if (!Array.isArray(items)) throw new Error(listing.malformed); yield* items; seen += items.length; @@ -601,7 +620,7 @@ async function authProviderOf( toolkit: string, ): Promise { const page = await client.tools.list({ toolkit }); - const items = page?.items; + const items = sentItemsOf(page); if (!Array.isArray(items)) { throw new Error( `Arcade did not answer with an action list for ${toolkit}, so the auth provider its connections are held under could not be read.`, @@ -645,8 +664,11 @@ export function createArcadeClient(apiKey: string): { offset: q.offset, }); // Passed through unchecked: a malformed listing is the transport's sentence to write, and - // its own `Array.isArray` guard is what writes it. - const items = record(answer)?.items as unknown[]; + // its own `Array.isArray` guard is what writes it. THE ITEMS ARE READ OFF THE RAW BODY for + // {@link sentItemsOf}'s reason — handing over the page's manufactured `[]` would leave that + // guard as dead as the totals the comment above describes, and a listing that answered no + // items at all would refresh an app's actions down to none. + const items = sentItemsOf(answer) as unknown[]; const total = reportedTotalOf(answer); return total === undefined ? { items } : { items, total_count: total }; }, diff --git a/server/tests/arcade-malformed-listing-engine.test.ts b/server/tests/arcade-malformed-listing-engine.test.ts new file mode 100644 index 000000000..9ec146a1c --- /dev/null +++ b/server/tests/arcade-malformed-listing-engine.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { listTools, useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** + * EVERY "Arcade did not answer with a list" SENTENCE, AGAINST A REAL HTTP ENGINE. + * + * These guards cannot be tested in the style of the rest of the adapter suite, which stubs the + * SDK's resource PROTOTYPES and so decides for itself what the adapter is handed. The defect they + * exist for lives BETWEEN the wire and the adapter: `OffsetPage`'s constructor does + * `this.items = body.items || []`, so an answer that carries no `items` at all reaches a reader of + * the PAGE OBJECT as an empty array — a malformed listing wearing an empty one's clothes. A stub + * that hands the adapter its own object never manufactures that default, so a test in that style + * passes whether the guard reads the page or the body it was built from. + * + * So, as in `arcade-pagination-engine.test.ts`, the engine here serves real JSON over a real socket + * through `ARCADE_BASE_URL` and the SDK builds its own pages. The malformed body is `{"error": + * "nope"}`: a well-formed JSON object, 200, with no `items` — what an engine answering an error + * shape on the success path sends. + */ + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +/** Which of the three listings answers the malformed body. */ +type Malformed = "tools" | "connections" | "authProviders"; + +type Engine = { port: number; stop: () => void }; + +const MALFORMED = { error: "nope" }; + +const toolRow = (toolkit: string) => ({ + qualified_name: `${toolkit}.Action0`, + description: "", + toolkit: { name: toolkit, description: "" }, + input: { parameters: [] }, + requirements: { + authorization: { provider_id: "google", oauth2: { scopes: [] } }, + }, +}); + +function startEngine(malformed: Malformed): Engine { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + const limit = Number(url.searchParams.get("limit") ?? "100"); + const offset = Number(url.searchParams.get("offset") ?? "0"); + + if (url.pathname === "/v1/admin/user_connections") { + if (malformed === "connections") return Response.json(MALFORMED); + return Response.json({ items: [], offset, limit, page_count: 0 }); + } + if (url.pathname === "/v1/admin/auth_providers") { + if (malformed === "authProviders") return Response.json(MALFORMED); + return Response.json({ + items: [ + { id: "google-openbot", provider_id: "google", type: "oauth2" }, + ], + offset: 0, + limit, + page_count: 1, + total_count: 1, + }); + } + if (url.pathname === "/v1/tools") { + if (malformed === "tools") return Response.json(MALFORMED); + const toolkit = url.searchParams.get("toolkit") ?? "Gmail"; + const page = offset === 0 ? [toolRow(toolkit)] : []; + return Response.json({ + items: page, + offset, + limit, + page_count: page.length, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(malformed: Malformed): void { + running = startEngine(malformed); + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +describe("a listing with no items refuses rather than reading as empty", () => { + test("listApps refuses a tool listing that is not a list", async () => { + engine("tools"); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.listApps()).rejects.toThrow( + /Arcade did not answer with a tool list/, + ); + }); + + test("grantsFor refuses a tool listing that is not a list", async () => { + engine("tools"); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.grantsFor({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/Arcade did not answer with an action list for Gmail/); + }); + + test("isConnected refuses a tool listing that is not a list", async () => { + // Through `authProviderOf`, the one unpaged listing in the adapter. + engine("tools"); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow( + /Arcade did not answer with an action list for Gmail, so the auth provider/, + ); + }); + + test("the transport refuses a tool listing that is not a list", async () => { + engine("tools"); + const { actions } = createArcadeClient("never-dialled"); + useArcadeClient(actions); + + await expect(listTools({ url: "arcade://Gmail" })).rejects.toThrow( + /Arcade did not answer with an action list for Gmail\. Nothing was refreshed/, + ); + }); + + test("isConnected refuses an auth-provider listing that is not a list", async () => { + engine("authProviders"); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/Arcade did not answer with an auth-provider list/); + }); + + test("isConnected refuses a connection listing that is not a list", async () => { + engine("connections"); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/Arcade did not answer with a connection list/); + }); + + test("revoke refuses a connection listing that is not a list", async () => { + engine("connections"); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + /Arcade did not answer with a connection list/, + ); + }); +}); From 2ef8a256eefba5fa29a797a31bcd408c02109602 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:45:38 +0200 Subject: [PATCH 127/232] fix(arcade): read a connection's own connection_status before calling it connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-connection row carries `connection_status` — the field is in the key set this file quotes verbatim at `connectionsFor`, and the SDK declares it (`connection_status?: string`) — and NOTHING read it. The adapter took a row's mere EXISTENCE as the answer, so `isConnected` answered `true` and `accountName` answered a name for a connection Arcade was reporting as expired, revoked at the provider, pending or failed. Nothing presented as wrong until a tool call failed later, far from the screen that said Connected, with no way back but guessing. The seam's own word for `isConnected` is "currently attached to this app at the vendor", and a finished connection is not that. THE DESIGN, AND WHICH WAY EACH UNKNOWN FALLS: - A row is LIVE when its `connection_status` is `"active"` — the one value measured on a usable connection at the live API, and the spelling this tree's own engine fixture already carried. An ALLOWLIST of the live spelling and not a denylist of dead ones: the SDK types the field as a bare string with no union, so the dead vocabulary cannot be enumerated from here, and a denylist missing one spelling would go on reading that spelling as connected — the defect itself. - A row with NO status, or an empty one, still reads as LIVE. The field is optional on the wire, so a pessimistic reading would answer "not connected" for every real connection against any engine that omits it: `isConnected` false, `accountName` null, `revoke` false WITHOUT SPENDING A DELETE. That is exactly the live defect 28b13e4a fixed, re-entered one field along, and it is the worse direction — a live connection reading as absent strands somebody with no path back, where a live-but-unrecognised status at worst sends them round a consent they already gave. `grantStateOf` already draws this line: not knowing is not the same as not connected. - `revoke` ASKS ON EVERY ROW, live or dead, which is why the liveness test is not a filter inside `connectionsFor`. A dead row is still a row at the vendor; skipping it would leave it standing while `revoke` answered `false` ("there was nothing to withdraw"), and a later reconnect would be made beside debris nobody can see. The two kinds of caller need opposite things from the same row, so the listing stays whole and only the POSITIVE reads ask the question. - Folded and trimmed, Amendment 16's client-side fold, as every other comparison in this file is. THE GRANTS VIEW NEEDS NOTHING HERE: `grantsFor` answers out of the per-person tool listing's own `requirements.authorization` (`token_status`, `status`) rather than out of connection rows, and `grantStateOf` already reads both negatives. SAME-PATTERN AUDIT OF THE WHOLE FILE — one other vendor field is named in a docblock and never read, and it is DELIBERATELY left unread: an auth-provider config's `status` (`"status":"active"` in the verbatim block at `authProviderInstances`). That listing is the ATTRIBUTION BRIDGE — it says which vendor a connection row belongs to — not a liveness claim about anybody's connection. Dropping inactive instances from it would make rows under them UNATTRIBUTABLE, which does not merely read as "not connected": it also stops `revoke` from deleting them, so an operator disabling a provider configuration would silently make everybody's Disconnect a no-op. Whether a person's connection still works is the connection row's own `connection_status`, and that is now read. Everything else the file touches already reads its negative: `grantStateOf`'s secret `met`, `token_status`/`status`, the authorize answer's `url`/`id`, and `requirements.met`, whose non-reading is argued in place. RED-GREEN, against `arcade-pagination-engine.test.ts`'s real-socket engine rather than the prototype stubs, which cannot see this class at all. Red, verbatim: (fail) ... isConnected and accountName refuse a row whose connection_status is not live Expected: false / Received: true (fail) ... a live row beside a dead one answers for the live one Expected: "person 1" / Received: "person 0" Four cases now stand: a dead row is not a connection; a live row beside a dead one is the one answered for; a row with no status at all still connects; and `revoke` deletes both and says it asked. CALL SITES of everything added or resignatured, all of them module-private: - `connectionIsLive` (new): `broker.isConnected`, `broker.accountName`. Nothing else, deliberately — `revoke` above. - `LIVE_CONNECTION_STATUS` (new): `connectionIsLive` only. - `connectionsFor`: unchanged signature and unchanged answer. - test helper `connectionRow` gained an optional second argument (one call site, in its own file), `EngineOptions` an optional `connectionStatuses`, and `statusOf` is new beside them. No exported symbol changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 67 +++++++++++++- server/tests/arcade-pagination-engine.test.ts | 87 ++++++++++++++++++- 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index b966b628d..0bef6630e 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -384,6 +384,13 @@ async function authProviderInstances( * deleted while a connection outlives it. That is fail-closed: the row is left alone rather than * deleted on a guess about which vendor it belongs to. * + * EVERY ATTRIBUTABLE ROW IS RETURNED, DEAD ONES INCLUDED, AND LIVENESS IS THE CALLER'S QUESTION. + * A row carries its own `connection_status`, and {@link connectionIsLive} reads it — but here, + * because the two kinds of caller need opposite things from a row the vendor reports as finished: + * `isConnected` and `accountName` must not count it, and `revoke` must still delete it. Filtering it + * out at this level would answer both at once, and the one it answered wrongly would be the + * destructive one. + * * AN UNRESOLVED TOOLKIT (`null`) ANSWERS AS NO ROWS and does NOT fall back to the toolkit name: a * fallback would silently restore the defect under a different spelling, invisibly against every * fixture in the tree. Callers then answer `isConnected` false, `revoke` false (no delete spent), @@ -420,6 +427,48 @@ async function connectionsFor( return rows; } +/** + * The one `connection_status` measured on a usable connection at the live API: `"active"`. + * + * AN ALLOWLIST OF THE LIVE SPELLING RATHER THAN A DENYLIST OF THE DEAD ONES, because the dead + * vocabulary cannot be enumerated from here — the SDK declares the field as a bare + * `connection_status?: string` with no union, and a denylist missing one spelling would go on + * reading that spelling as connected, which is the defect itself. + */ +const LIVE_CONNECTION_STATUS = "active"; + +/** + * Whether a connection row is one somebody can actually USE, read off the row's OWN `connection_status`. + * + * THE ROW'S EXISTENCE IS NOT THE ANSWER, WHICH IS WHAT THIS IS FOR. A user-connection row carries + * `connection_status` beside its ids — it is in the key set {@link connectionsFor} quotes verbatim + * — and that field is the vendor saying whether the connection still works. Treating a row's mere + * presence as a yes answers `isConnected: true`, with a name beside it, for a connection Arcade is + * reporting as expired, revoked at the provider, pending or failed. Nothing then goes wrong until a + * tool call fails, far from the screen that said Connected, with no way back but guessing. + * + * SILENCE IS NOT A NO. An ABSENT or empty status reads as live, deliberately: the field is OPTIONAL + * on the wire (`connection_status?: string`), so a pessimistic reading would answer "not connected" + * for every real connection against any engine that omits it — `isConnected` false, `accountName` + * null and `revoke` false WITHOUT SPENDING A DELETE, which is precisely the live defect this seam + * already suffered once when it compared a provider TYPE to a provider INSTANCE. The rule is + * therefore: the vendor said something other than live → not live; the vendor said nothing → the + * existence of the row is all the evidence there is, and it stands. + * + * FOLDED AND TRIMMED, Amendment 16's client-side fold, for the reason every other comparison in + * this file is: a case the vendor changes must not change an answer about somebody's account. + * + * NOT A FILTER ON {@link connectionsFor}. `revoke` must still delete a dead row — it is a row at the + * vendor, and leaving it behind as unreachable debris while answering `false` ("there was nothing") + * is the failure mode `revoke`'s own contract forbids. So the listing stays whole and only the + * POSITIVE reads — `isConnected`, `accountName` — ask this question. + */ +function connectionIsLive(row: ConnectionRow): boolean { + const status = textOf(row.connection_status)?.trim().toLowerCase(); + if (status === undefined || status === "") return true; + return status === LIVE_CONNECTION_STATUS; +} + /** * DELETE TAKES `connection_id` (`ac_...`), NOT `id` (`at_...`). A user-connection row carries both * and the listing prints `id` FIRST; the SDK declares `delete(id: string)` and does not say which. @@ -680,11 +729,16 @@ export function createArcadeClient(apiKey: string): { accountName: async ({ account, toolkit }) => { // Filtered by the row's own provider, as Composio's `accountsFor(..., toolkit, ...)` is: a // person connected to two apps must never be shown the OTHER app's name on this app's screen. + // + // AND BY THE ROW'S OWN `connection_status`, through {@link connectionIsLive}: this name is + // shown beside "Connected", so a name read off a row the vendor reports as expired or revoked + // says the connection is live in the one place somebody looks to check. for (const row of await connectionsFor( client, account.vendorUserId, toolkit, )) { + if (!connectionIsLive(row)) continue; const info = row.provider_user_info as | { name?: unknown; login?: unknown } | null @@ -731,11 +785,22 @@ export function createArcadeClient(apiKey: string): { return { redirectUrl: url, authorizationId }; }, // 6.2: the connection-lifecycle trio. + // A LIVE ROW AND NOT MERELY A ROW. The seam's word is "currently attached to this app at the + // vendor", and a row Arcade reports as expired, revoked, pending or failed is not that; see + // {@link connectionIsLive}, which also says why a row with NO status still counts. isConnected: async ({ account, toolkit }) => - (await connectionsFor(client, account.vendorUserId, toolkit)).length > 0, + (await connectionsFor(client, account.vendorUserId, toolkit)).some( + connectionIsLive, + ), // Answers what was REQUESTED: true when something was found and every delete was accepted, // false when there was nothing. Every delete is attempted, then ANY failure throws: a partial // ask must not read as `false` ("nothing there") nor as a clean `true`. + // + // ON EVERY ROW, LIVE OR NOT, WHICH IS WHY THIS DOES NOT ASK {@link connectionIsLive}. A dead row + // is still a row at the vendor: skipping it would leave it standing while this answered `false` + // ("there was nothing to withdraw"), and a reconnect would then be made beside debris nobody can + // see. Deleting a connection the vendor already considers finished costs one call and is the + // only reading under which Disconnect means what it says. revoke: async ({ account, toolkit }) => { const rows = await connectionsFor(client, account.vendorUserId, toolkit); if (rows.length === 0) return false; diff --git a/server/tests/arcade-pagination-engine.test.ts b/server/tests/arcade-pagination-engine.test.ts index 6df7bd068..c2820a1d0 100644 --- a/server/tests/arcade-pagination-engine.test.ts +++ b/server/tests/arcade-pagination-engine.test.ts @@ -33,6 +33,12 @@ type EngineOptions = { connectionTotal: boolean; /** Serve this many connection rows and no more, while still reporting the full `total_count`. */ connectionTruncateAt?: number; + /** + * The `connection_status` each row carries, row by row: a string is sent verbatim, `null` OMITS + * the field as the wire may (the SDK declares it optional), and a row this does not reach carries + * the live `"active"` the live API answers with. + */ + connectionStatuses?: (string | null)[]; /** The toolkit listing claims this many actions... */ toolsClaimed?: number; /** ...and serves only this many, dropping `total_count` from the short final page. */ @@ -46,10 +52,11 @@ type Engine = { stop: () => void; }; -const connectionRow = (n: number) => ({ +const connectionRow = (n: number, status: string | null = "active") => ({ id: `at_${n}`, connection_id: `ac_${n}`, - connection_status: "active", + // OMITTED when the engine says `null`, as the wire may omit it. + ...(status === null ? {} : { connection_status: status }), // A provider INSTANCE id, never a provider type: see arcade-adapter-connections.test.ts. provider_id: "google-openbot", provider_type: "oauth2", @@ -68,10 +75,16 @@ const toolRow = (toolkit: string, n: number) => ({ }, }); +function statusOf(options: EngineOptions, n: number): string | null { + const listed = options.connectionStatuses?.[n]; + return listed === undefined ? "active" : listed; +} + function startEngine(options: EngineOptions): Engine { const requests: string[] = []; const rows = Array.from({ length: options.connections }, (_, n) => - connectionRow(n), + // `undefined` (unlisted) is the live default; `null` means OMIT the field, so it is kept apart. + connectionRow(n, statusOf(options, n)), ); const served = options.connectionTruncateAt ?? rows.length; const server = Bun.serve({ @@ -197,6 +210,74 @@ describe("the connection listing is read to its end, not to the SDK's", () => { }); }); +/** + * `connection_status` IS THE ROW'S OWN ANSWER TO "IS THIS USABLE", AND THE POSITIVE READS TAKE IT. + * + * A row's EXISTENCE used to be the whole of the answer, so a connection the vendor was reporting as + * no longer usable — expired, revoked at the provider, pending, failed — read as `isConnected: true` + * with a name beside it, and the only way to find out was a tool call failing later with nothing on + * the screen to explain it. + * + * The three cases below are the three halves of the design: a reported-dead row is not a connection, + * a row the vendor said NOTHING about is still one (the field is optional on the wire, and reading + * silence as dead would un-connect everybody against any engine that omits it), and `revoke` acts on + * BOTH — a dead row is still a row to delete, and leaving it would be debris nobody can reach. + */ +describe("a connection the vendor reports as dead is not a live connection", () => { + test("isConnected and accountName refuse a row whose connection_status is not live", async () => { + engine({ + connections: 1, + connectionTotal: false, + connectionStatuses: ["expired"], + }); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(false); + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe(null); + }); + + test("a live row beside a dead one answers for the live one", async () => { + engine({ + connections: 2, + connectionTotal: false, + connectionStatuses: ["expired", "active"], + }); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + // `person 0`'s row is the dead one, so its name must not be the one shown. + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe( + "person 1", + ); + }); + + test("a row carrying no connection_status at all still reads as connected", async () => { + engine({ + connections: 1, + connectionTotal: false, + connectionStatuses: [null], + }); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe( + "person 0", + ); + }); + + test("revoke still deletes a dead row, and says it asked", async () => { + const e = engine({ + connections: 2, + connectionTotal: false, + connectionStatuses: ["expired", "active"], + }); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.revoke({ account, toolkit: "Gmail" })).toBe(true); + expect(deletes(e).length).toBe(2); + }); +}); + describe("the action listing's total_count cross-check survives a short final page", () => { test("the transport refuses a listing truncated at 250 of 300", async () => { engine({ From 2b4b2cbd7163b0296ab21b9f649e7f10b016f9fb Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:46:37 +0200 Subject: [PATCH 128/232] fix(db): hold pending_authorizations to the unions it declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pending_authorizations` declared two TypeScript unions — `holder` as "person" | "deployment" and `outcome` as the four-name roster — and the migration that created it, `0057_arcade_provider.sql`, added no CHECK for either, in the same file that drops and re-adds its sibling's `brokered_connections_provider_check`. `$type<>` is erased at runtime, so the database accepted 'llama' as a holder and 'banana' as an outcome and drizzle handed both back typed as the declared unions: a `switch` over `outcome` the compiler believes exhaustive was not. The same table also permitted exactly the two holder/`user_id` combinations its sibling forbids — a person attempt with a null `user_id` and a deployment attempt carrying one — because `user_id` is nullable, so `InferInsertModel` makes it optional and TypeScript raises nothing. `brokered_connections_holder_check` exists precisely to make those impossible; the new table now carries the same pair, under the same names. Two CHECKs, modelled on the sibling and on `mcp_servers_account_mode_check` for the nullable column: pending_authorizations_holder_check pending_authorizations_outcome_check Migration `0058_pending_authorization_checks.sql`, generated with `drizzle-kit generate`. `0057`, `_journal.json`'s existing entries and every existing snapshot are untouched; the journal gains only its append, and `0058_snapshot.prevId == 0057_snapshot.id`. `drizzle-kit check` passes. Applied cleanly to a fresh database and to the already-migrated `openbot_test`. No row can violate either CHECK: the table is created by 0057 on this unmerged branch, both local databases hold zero rows, and its only writer, `claimAuthorizationAttempt`, already writes `user_id` only for a `person` holder and `outcome` only from the roster — the CHECKs are the backstop for a hand-written statement or a cast, not a correction. No symbol was added, renamed, resignatured or removed. `pendingAuthorizations` keeps its name, its columns and its inferred types; the `pgTable` call simply gains the extra-config argument the sibling already passes, so no call site changes. `bun run typecheck` is green. Whole-of-plugins.ts audit for the same pattern: the other three `$type<>` unions are already held — `mcp_servers.account_mode` by `mcp_servers_account_mode_check`, `brokered_connections.provider` by `brokered_connections_provider_check`, and `brokered_connections.holder` by `brokered_connections_holder_check`, whose two branches pin it to the pair. These two were the only unheld ones. Deliberately left alone: `mcp_servers.provenance`, which is plain `text` with no declared union and so makes the compiler no promise, and the many `$type<>` unions in the other schema files, which are a different finding and a different table. Tests: server/tests/arcade-attempt-constraints.test.ts, run red against a database holding 0057's shape (every refusal read "accepted") and green after 0058. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- .../0058_pending_authorization_checks.sql | 2 + server/drizzle/meta/0058_snapshot.json | 9960 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 57 +- .../tests/arcade-attempt-constraints.test.ts | 128 + 5 files changed, 10138 insertions(+), 16 deletions(-) create mode 100644 server/drizzle/0058_pending_authorization_checks.sql create mode 100644 server/drizzle/meta/0058_snapshot.json create mode 100644 server/tests/arcade-attempt-constraints.test.ts diff --git a/server/drizzle/0058_pending_authorization_checks.sql b/server/drizzle/0058_pending_authorization_checks.sql new file mode 100644 index 000000000..a940de2bd --- /dev/null +++ b/server/drizzle/0058_pending_authorization_checks.sql @@ -0,0 +1,2 @@ +ALTER TABLE "pending_authorizations" ADD CONSTRAINT "pending_authorizations_holder_check" CHECK (("pending_authorizations"."holder" = 'person' AND "pending_authorizations"."user_id" IS NOT NULL) OR ("pending_authorizations"."holder" = 'deployment' AND "pending_authorizations"."user_id" IS NULL));--> statement-breakpoint +ALTER TABLE "pending_authorizations" ADD CONSTRAINT "pending_authorizations_outcome_check" CHECK ("pending_authorizations"."outcome" IS NULL OR "pending_authorizations"."outcome" IN ('completed', 'failed', 'unreconciled', 'withdrawn')); \ No newline at end of file diff --git a/server/drizzle/meta/0058_snapshot.json b/server/drizzle/meta/0058_snapshot.json new file mode 100644 index 000000000..41e065ff1 --- /dev/null +++ b/server/drizzle/meta/0058_snapshot.json @@ -0,0 +1,9960 @@ +{ + "id": "d7748e18-de30-4092-afcd-21350f0829a1", + "prevId": "529de577-9b19-43d1-9885-dd32ddae9bd6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "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": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "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": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_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": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_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": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index df766370d..579c04461 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -407,6 +407,13 @@ "when": 1791908858814, "tag": "0057_arcade_provider", "breakpoints": true + }, + { + "idx": 58, + "version": "7", + "when": 1791924168752, + "tag": "0058_pending_authorization_checks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 3eac3a74d..f115d5992 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -461,22 +461,47 @@ export const brokeredConnections = pgTable( * setting it, and a second return finds it set. `auth_id` is what binds a callback to this attempt; * an attempt holding null can never complete. */ -export const pendingAuthorizations = pgTable("pending_authorizations", { - actorId: text("actor_id").primaryKey(), - serverId: text("server_id") - .notNull() - .references(() => mcpServers.id, { onDelete: "cascade" }), - action: text("action").notNull(), - holder: text("holder").$type<"person" | "deployment">().notNull(), - userId: text("user_id"), - vendorUserId: text("vendor_user_id").notNull(), - authId: text("auth_id"), - createdAt: createdAt(), - completedAt: timestamp("completed_at", { withTimezone: true }), - outcome: text("outcome").$type< - "completed" | "failed" | "unreconciled" | "withdrawn" - >(), -}); +export const pendingAuthorizations = pgTable( + "pending_authorizations", + { + actorId: text("actor_id").primaryKey(), + serverId: text("server_id") + .notNull() + .references(() => mcpServers.id, { onDelete: "cascade" }), + action: text("action").notNull(), + /** + * Whose account this attempt is for, with the same meaning and the same two values as + * {@link brokeredConnections.holder} — and held by the same CHECK, because `$type<>` is erased + * at runtime and the row this attempt is reconciled into is held to that pair. + */ + holder: text("holder").$type<"person" | "deployment">().notNull(), + userId: text("user_id"), + vendorUserId: text("vendor_user_id").notNull(), + authId: text("auth_id"), + createdAt: createdAt(), + completedAt: timestamp("completed_at", { withTimezone: true }), + /** + * How the attempt ended, `null` while it is still in flight. + * + * The four names are a ROSTER the database holds, not a comment: every reader switches on them, + * and a fifth value written by hand — or by a caller that cast past the declared union — would + * reach a `switch` the compiler has already been told is exhaustive. + */ + outcome: text("outcome").$type< + "completed" | "failed" | "unreconciled" | "withdrawn" + >(), + }, + (table) => [ + check( + "pending_authorizations_holder_check", + sql`(${table.holder} = 'person' AND ${table.userId} IS NOT NULL) OR (${table.holder} = 'deployment' AND ${table.userId} IS NULL)`, + ), + check( + "pending_authorizations_outcome_check", + sql`${table.outcome} IS NULL OR ${table.outcome} IN ('completed', 'failed', 'unreconciled', 'withdrawn')`, + ), + ], +); /** * One person's grant on one MCP server: the row that makes a Bot answer as the asker. diff --git a/server/tests/arcade-attempt-constraints.test.ts b/server/tests/arcade-attempt-constraints.test.ts new file mode 100644 index 000000000..92b86cd66 --- /dev/null +++ b/server/tests/arcade-attempt-constraints.test.ts @@ -0,0 +1,128 @@ +// server/tests/arcade-attempt-constraints.test.ts +// +// `pending_authorizations` declares two unions in TypeScript — `holder` and `outcome` — and +// drizzle's `$type<>` is erased at runtime, so only the database can hold them. These are the +// checks the sibling table `brokered_connections` has had all along, asked of the new table: +// a holder outside the pair, an outcome outside the roster, and the two holder/`user_id` +// combinations `brokered_connections_holder_check` exists to make impossible. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { mcpServers, pendingAuthorizations } from "../src/db/schema/plugins"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const serverId = `arcade-constraints-${suite}`; + +/** An attempt row with every column legal, so each test varies exactly the one it is about. */ +const attempt = (overrides: Record) => ({ + actorId: `actor-${randomUUID().slice(0, 8)}`, + serverId, + action: "connect", + holder: "person" as const, + userId: `user-${suite}`, + vendorUserId: `v-${suite}`, + ...overrides, +}); + +// Awaited rather than handed back as a builder, so a failure says what the database did with the +// row and not what drizzle's query object looks like. +const insert = async (overrides: Record) => { + await database + .insert(pendingAuthorizations) + // biome-ignore lint/suspicious/noExplicitAny: the point is what the DATABASE refuses, past the declared unions. + .values(attempt(overrides) as any); +}; + +/** + * The name of the constraint that refused a statement, or `"accepted"` if nothing did. + * + * Named rather than merely thrown, because "it threw" would also pass on a typo in a column name. + * drizzle's own message is only "Failed query: ..."; the constraint it tripped is `constraint` on + * the postgres error underneath. + */ +const refusedBy = async (run: () => Promise): Promise => { + try { + await run(); + } catch (error) { + const cause = (error as { cause?: { constraint?: string } }).cause; + return cause?.constraint ?? String(error); + } + return "accepted"; +}; + +beforeAll(async () => { + await database.insert(mcpServers).values({ + id: serverId, + title: "Attempt constraints", + vendor: "test", + url: "https://example.invalid/mcp", + }); +}); + +afterAll(async () => { + await database.execute(sql`delete from mcp_servers where id = ${serverId}`); + await database.$client.close(); +}); + +describe("pending_authorizations holds its declared unions", () => { + test("a holder outside the pair is refused", async () => { + expect(await refusedBy(() => insert({ holder: "llama" }))).toBe( + "pending_authorizations_holder_check", + ); + }); + + test("an outcome outside the roster is refused on insert", async () => { + expect(await refusedBy(() => insert({ outcome: "banana" }))).toBe( + "pending_authorizations_outcome_check", + ); + }); + + test("an outcome outside the roster is refused on update", async () => { + const actorId = `update-${suite}`; + await insert({ actorId }); + const refusal = await refusedBy(() => + database + .update(pendingAuthorizations) + // biome-ignore lint/suspicious/noExplicitAny: a cast past the union is exactly what the CHECK is for. + .set({ outcome: "cancelled" as any }) + .where(sql`actor_id = ${actorId}`), + ); + expect(refusal).toBe("pending_authorizations_outcome_check"); + }); + + test("a person attempt with no user id is refused", async () => { + expect( + await refusedBy(() => insert({ holder: "person", userId: null })), + ).toBe("pending_authorizations_holder_check"); + }); + + test("a deployment attempt carrying a user id is refused", async () => { + expect( + await refusedBy(() => + insert({ holder: "deployment", userId: `user-${suite}` }), + ), + ).toBe("pending_authorizations_holder_check"); + }); + + test("the legal shapes are all still accepted", async () => { + const action = `legal-${suite}`; + await insert({ action, holder: "person", userId: `user-${suite}` }); + await insert({ action, holder: "deployment", userId: null }); + for (const outcome of [ + null, + "completed", + "failed", + "unreconciled", + "withdrawn", + ]) { + await insert({ action, outcome }); + } + const rows = await database.execute( + sql`select count(*)::int as n from pending_authorizations where action = ${action}`, + ); + expect(rows[0]?.n).toBe(7); + }); +}); From f7de92feb8ccd60f4bf6b3f676a6d7b207711604 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:47:52 +0200 Subject: [PATCH 129/232] fix(broker): name the active vendor in a refused return address, and only to its operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `brokerReturnUrl` spelled `Composio` as a literal in both refusals and took no provider, so it could say nothing else. The deployments that can actually reach the authorize route's return-address check are Arcade ones — `routes.ts` gates that route on `broker.authorizations`, which only Arcade declares — so the one audience for this sentence was told its failure involved a vendor it does not use. The same sentence handed that reader `OPENBOT_APP_URL`, an environment variable only an operator can set, on two `requireUser` routes. The refusal is now written twice, the division `BrokerUnconfiguredError` already draws between a `person` and an `operator`: `message` — what the route answers with — names no vendor and no variable and says an administrator can correct the address, and `operatorMessage` names the ACTIVE provider (from `BROKERED_PROFILE`) and the variable, and is logged rather than answered. Had the operator's half simply been dropped, a misconfigured address would have become invisible to the only reader who can fix it. CALL SITES OF `brokerReturnUrl`, ENUMERATED. Two in `src`, both in `server/src/plugins/routes.ts`, both `requireUser` and both now passing `activeProvider` and logging the operator's half through `logReturnUrlRefusal`: - `POST /servers/:id/connect` (the brokered branch, mint of a connect link) - `POST /servers/:id/authorize` (the per-action authorization mint) Seven in `server/tests/composio-broker-seam.test.ts`, updated. No other caller exists in `server/`, `app/`, `worker/` or any script; `BrokerReturnUrlError` was constructed nowhere but inside `brokerReturnUrl` itself. SAME-PATTERN AUDIT OF `broker.ts`. The only other message-bearing strings in the file are `BrokerUnconfiguredError` (already provider-keyed and reader-split) and `assertActivatable`, which names the vendor it is actually refusing, keyed on its `candidate` argument, in a boot-time refusal no person can reach. Neither is this defect. Comments that said "Composio" where they meant "the vendor" were corrected where they sat on this code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/broker.ts | 75 ++++++++---- server/src/plugins/routes.ts | 39 ++++++- server/tests/broker-return-url-voice.test.ts | 114 +++++++++++++++++++ server/tests/composio-broker-seam.test.ts | 46 +++++--- 4 files changed, 236 insertions(+), 38 deletions(-) create mode 100644 server/tests/broker-return-url-voice.test.ts diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 2107882c4..a293e551a 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -17,10 +17,11 @@ */ // This import closes a runtime cycle: `./access` imports `schemeKind` from this file. It is safe // for two reasons, and the reasons are the rule. `access.ts` reads `schemeKind` only inside a -// function body, and this file reads `BROKERED_PROFILE` only inside the `BrokerUnconfiguredError` -// constructor, so whichever module evaluates first, the other's half-initialised bindings are never -// touched while either is still evaluating. Nothing may call across this cycle at module scope. -// Hoisting the `BROKERED_PROFILE[provider]` destructure out of that constructor, or deriving a +// function body, and this file reads `BROKERED_PROFILE` only inside a refusal constructor — the +// `BrokerUnconfiguredError` one and the `BrokerReturnUrlError` one — so whichever module evaluates +// first, the other's half-initialised bindings are never touched while either is still evaluating. +// Nothing may call across this cycle at module scope. +// Hoisting a `BROKERED_PROFILE[provider]` destructure out of either constructor, or deriving a // top-level constant in `access.ts` from `schemeKind`, would read a lexical binding the other module // has not yet initialised: it throws under one evaluation order and not the other, so it can pass // every test and still fail at boot. The cycle is deliberate: passing `vendor` and `envVar` in @@ -886,20 +887,50 @@ export class BrokerUnconfiguredError extends BrokerRefusalError { /** * A return address that could not bring anybody back, refused before a consent is spent on it. * - * ITS OWN CLASS BECAUSE ITS REMEDY IS ITS OWN. Every other refusal in this file is about Composio — - * a key that is not set, a config this deployment never made, a consent the vendor answered with no - * page. This one is about this deployment's own address for itself, and the person who can act on it - * is an operator with `OPENBOT_APP_URL` in front of them. A caller that could not tell the two apart - * would send somebody to check a Composio key that is perfectly fine. + * ITS OWN CLASS BECAUSE ITS REMEDY IS ITS OWN. Every other refusal in this file is about the broker + * — a key that is not set, a config this deployment never made, a consent the vendor answered with + * no page. This one is about this deployment's own address for itself, and the reader who can act + * on it is an operator with `OPENBOT_APP_URL` in front of them. A caller that could not tell the + * two apart would send somebody to check a vendor key that is perfectly fine. * * A {@link BrokerRefusalError} because it keeps that class's promise about the message: the sentence * is written here, names the step that fixes it, and carries no url. Which matters more than usual * for this one — the value it is refusing is the thing a bad message would be tempted to quote, and * an address is the half of a connect link that says which deployment and which person it is for. + * + * TWO SENTENCES, BECAUSE THE READER WHO MEETS THIS IS NOT THE READER WHO CAN FIX IT, and that is + * the whole of what this class now settles. Both call sites are `requireUser` routes: whoever reads + * `message` is somebody who pressed Connect, and `OPENBOT_APP_URL` is an environment variable they + * cannot set and a vendor name is vocabulary spec D11 keeps off their screen — the same division + * {@link BrokerUnconfiguredError} draws between its `person` and its `operator`. So `message` says + * what happened and who to ask, and {@link operatorMessage} — which the route logs rather than + * answers — names the ACTIVE provider and the variable. A single sentence had to fail one of them, + * and the one it failed was the person: it spelled `Composio` as a literal on a function that took + * no provider, so the Arcade deployments that are the only ones able to reach the authorize route's + * refusal were told their failure involved a vendor they do not use. + * + * THE OPERATOR'S HALF IS HELD AND NOT RAISED, so {@link brokerSentence} keeps answering with the + * safe sentence and a route that passes a refusal through cannot pass this one through by accident. + * Neither half quotes the address, for the reason given on {@link brokerReturnUrl}: a log line is + * one of the places that message was always promised not to reach. */ export class BrokerReturnUrlError extends BrokerRefusalError { - constructor(message: string) { - super(message); + /** + * The same refusal written for whoever can act on it, for a log and never for a response body. + */ + readonly operatorMessage: string; + + constructor(provider: BrokerProvider, address: "unbuilt" | "not-a-web-page") { + const { vendor } = BROKERED_PROFILE[provider]; + super( + address === "unbuilt" + ? "This deployment built no address to send you back to, so connecting was not begun rather than begun with nowhere to land. Nothing was asked of your account; an administrator can set this deployment's address up." + : "The address this deployment would send you back to is not a web address, so a consent granted now would end on a page with no way back here. Nothing was asked of your account; an administrator can correct this deployment's address.", + ); + this.operatorMessage = + address === "unbuilt" + ? `This deployment built no address for ${vendor} to send a person back to, so the connection was not begun rather than begun with nowhere to land. Set OPENBOT_APP_URL to the address this deployment's pages are served from, and connecting an app will have a return leg.` + : `The address ${vendor} would send a person back to is not a web address, so a consent granted there would end on ${vendor}'s own page with no way back here. Set OPENBOT_APP_URL to this deployment's own origin including the scheme — https://openbot.example.com rather than openbot.example.com.`; this.name = "BrokerReturnUrlError"; } } @@ -916,21 +947,24 @@ export class BrokerReturnUrlError extends BrokerRefusalError { * TWO REFUSALS, BECAUSE THEY ARE TWO DIFFERENT MISTAKES. An empty address is a caller that built * none — the guard in front of this one did not run, or ran against the wrong value. An address that * is not a web page is a configured one that cannot work: `OPENBOT_APP_URL` set to - * `openbot.example.com`, which no browser can resolve from Composio's origin, or to `localhost:3001`, + * `openbot.example.com`, which no browser can resolve from the vendor's origin, or to `localhost:3001`, * where `localhost:` is read as the scheme. Both are reachable from the settings this deployment * actually ships — the variable is an environment string and nothing between it and the vendor looks * at it — and both end with the same person on the same hosted page with nowhere to go. * * IT NAMES THE SETTING AND NOT THE VALUE. The setting is the remedy, and it is the same one whether * the address arrived empty or malformed; the value is a page address for one person's connection - * and belongs in no message, no log and no audit row, for the reason the connect url does not. + * and belongs in no message, no log and no audit row, for the reason the connect url does not. And + * it names it to the OPERATOR ALONE: the provider passed in picks that reader's vendor, while the + * sentence the person reads names neither it nor the variable, which {@link BrokerReturnUrlError} + * argues at length. * * WHAT COMES BACK IS THE ADDRESS THAT WAS CHECKED, WHICH IS NOT ALWAYS THE STRING THAT WENT IN. The * check reads a parsed address: the emptiness test trims, and parsing drops the spaces and control * characters a URL cannot contain — so ` https://openbot.test/…`, an address ending in a newline and * one with a tab inside its host all satisfy this guard while denoting something else entirely. A * version that approved the parsed address and returned the raw one approved nothing: the padding - * travelled on to Composio as part of the callback, which is the person stranded on a vendor page + * travelled on to the vendor as part of the callback, which is the person stranded on a vendor page * that this function exists to prevent. Returning what was read is what makes the reading binding. * * THAT IS A READING OF THE ADDRESS AND NOT A CHOICE ABOUT IT. Where somebody lands holding a @@ -940,17 +974,16 @@ export class BrokerReturnUrlError extends BrokerRefusalError { * at an address that means nothing, which is why the branch above refuses rather than repairs: a * missing scheme is a setting to fix and not whitespace to drop. */ -export function brokerReturnUrl(returnUrl: string): string { +export function brokerReturnUrl( + returnUrl: string, + provider: BrokerProvider, +): string { if (returnUrl.trim() === "") { - throw new BrokerReturnUrlError( - "This deployment built no address for Composio to send you back to, so the connection was not begun rather than begun with nowhere to land. Set OPENBOT_APP_URL to the address this deployment's pages are served from, and connecting an app will have a return leg.", - ); + throw new BrokerReturnUrlError(provider, "unbuilt"); } const address = webAddress(returnUrl); if (address === null) { - throw new BrokerReturnUrlError( - "The address Composio would send you back to is not a web address, so a consent granted there would end on Composio's own page with no way back here. Set OPENBOT_APP_URL to this deployment's own origin including the scheme — https://openbot.example.com rather than openbot.example.com.", - ); + throw new BrokerReturnUrlError(provider, "not-a-web-page"); } return address.href; } diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 95048893f..836806a61 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -12,6 +12,7 @@ import { type AuthorizationFlow, type BrokerApp, type BrokerField, + BrokerReturnUrlError, BrokerUnconfiguredError, brokerReturnUrl, brokerSentence, @@ -110,6 +111,33 @@ function brokerRefusal( return { error: vendorSentence(error) ?? generic, status: 502 }; } +/** + * A return address this deployment could not use, said once where an operator will read it. + * + * THE REMEDY AND THE READER HAD COME APART, WHICH IS WHAT THIS CLOSES. Both routes that check a + * return address are `requireUser`, so the sentence they answer with reaches somebody who pressed + * Connect — and `OPENBOT_APP_URL` is a variable they cannot set, named beside a vendor they may not + * even be on. {@link BrokerReturnUrlError} answers them with neither now, which would leave the + * operator's half nowhere at all if it were not written here: a misconfigured address would be + * invisible to the one person who can correct it. + * + * A LOG AND NOT A RESPONSE, because that is the difference between the two readers. The log is the + * operator's screen, and the sentence it carries is the error's own — written in `./broker`, naming + * the ACTIVE provider, and quoting no address, which is the promise that makes it safe to write + * down at all. Anything else thrown past this is somebody else's to explain, so it is ignored here + * rather than logged generically. + */ +function logReturnUrlRefusal(error: unknown, serverId: string): void { + if (!(error instanceof BrokerReturnUrlError)) return; + console.error( + JSON.stringify({ + type: "broker-return-url-unusable", + serverId, + note: error.operatorMessage, + }), + ); +} + /** * What a reader is told when the active broker would not answer with its directory. * @@ -1923,13 +1951,20 @@ export function createPluginRoutes( * `openbot.example.com` with the scheme left off builds a callback that is not a * callback. That failure lands after somebody has consented, on the vendor's page, where * this deployment cannot tell them anything; the guard moves it to before the link is - * minted, where the sentence reaches an operator who can set the variable. + * minted, where nobody has spent a consent on it yet. + * + * WHO IS READING DECIDES WHAT IS SAID, AND HERE THAT IS A PERSON. This route is + * `requireUser`: whoever pressed Connect gets the refusal's own sentence, which names no + * vendor and no variable because they can act on neither. The operator's half of it is + * logged below instead, where an operator looks and a person does not. */ returnUrl: brokerReturnUrl( connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + activeProvider, ), })); } catch (error) { + logReturnUrlRefusal(error, serverId); const refusal = brokerRefusal( error, `Composio would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, @@ -2560,8 +2595,10 @@ export function createPluginRoutes( try { returnUrl = brokerReturnUrl( connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + activeProvider, ); } catch (error) { + logReturnUrlRefusal(error, serverId); const refusal = brokerRefusal( error, "This deployment could not build the address to send you back to, so nothing was started.", diff --git a/server/tests/broker-return-url-voice.test.ts b/server/tests/broker-return-url-voice.test.ts new file mode 100644 index 000000000..c60fc808a --- /dev/null +++ b/server/tests/broker-return-url-voice.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { + BrokerReturnUrlError, + brokerReturnUrl, + brokerSentence, +} from "../src/plugins/broker"; + +/** + * Which provider a refused return address names, and who its remedy is written for. + * + * The sentences used to spell `Composio` as a literal, on a function that took no provider at all + * — so an Arcade deployment, which is the only kind that reaches the authorize route this refusal + * guards, told a person a failure involving a vendor it does not use. They also handed that person + * `OPENBOT_APP_URL`, which is an environment variable nobody but an operator can set. + * + * So these pin both halves, exactly as `broker-unconfigured-voice.test.ts` pins them for the + * unconfigured sentence: what reaches the person names no vendor and no setting (spec D11), and + * the operator's own sentence names the ACTIVE provider and the variable that fixes it. Neither + * quotes the address, which is the property the seam test already held and this must not loosen. + * + * ARITY IS A RUNTIME ASSERTION, NOT `@ts-expect-error`: `server/tests` is outside `tsc`, so an + * expect-error written here would assert nothing, and `Function.length` falls the moment the + * provider is made optional or defaulted back to Composio. + */ + +/** Every way a return address can be refused, with the address that provokes it. */ +const REFUSALS = [ + { kind: "never built", address: " " }, + { kind: "not a web address", address: "openbot.example.com/settings/x" }, +] as const; + +function refusalOf(address: string, provider: "composio" | "arcade") { + try { + brokerReturnUrl(address, provider); + } catch (error) { + if (error instanceof BrokerReturnUrlError) return error; + throw error; + } + throw new Error(`${address} was not refused`); +} + +describe("brokerReturnUrl, as a person reads it", () => { + for (const { kind, address } of REFUSALS) { + test(`an address ${kind} names no vendor and no setting`, () => { + for (const provider of ["composio", "arcade"] as const) { + const error = refusalOf(address, provider); + expect(error.message).not.toMatch(/composio|arcade/i); + expect(error.message).not.toMatch(/OPENBOT_APP_URL|API_KEY/); + // The route answers with this one, so it is the one that must be safe. + expect(brokerSentence(error)).toBe(error.message); + } + }); + + test(`an address ${kind} reads the same on either provider`, () => { + expect(refusalOf(address, "composio").message).toBe( + refusalOf(address, "arcade").message, + ); + }); + } + + test("the two mistakes stay two sentences", () => { + expect(refusalOf(" ", "arcade").message).not.toBe( + refusalOf("openbot.example.com/settings/x", "arcade").message, + ); + }); +}); + +describe("brokerReturnUrl, as an operator reads it", () => { + test("an Arcade deployment is told Arcade and never Composio", () => { + for (const { address } of REFUSALS) { + const { operatorMessage } = refusalOf(address, "arcade"); + expect(operatorMessage).toContain("Arcade"); + expect(operatorMessage).not.toMatch(/composio/i); + } + }); + + test("a Composio deployment is told Composio and never Arcade", () => { + for (const { address } of REFUSALS) { + const { operatorMessage } = refusalOf(address, "composio"); + expect(operatorMessage).toContain("Composio"); + expect(operatorMessage).not.toMatch(/arcade/i); + } + }); + + test("the remedy is named to the only reader who can apply it", () => { + for (const { address } of REFUSALS) { + expect(refusalOf(address, "arcade").operatorMessage).toContain( + "OPENBOT_APP_URL", + ); + } + }); + + test("neither sentence quotes the address it refused", () => { + const address = + "openbot-tenant-42.internal.corp/settings/connected-accounts/9f3c"; + const error = refusalOf(address, "arcade"); + + for (const sentence of [error.message, error.operatorMessage]) { + expect(sentence).not.toContain(address); + expect(sentence).not.toContain("openbot-tenant-42.internal.corp"); + expect(sentence).not.toContain("/settings/connected-accounts/9f3c"); + } + }); +}); + +describe("brokerReturnUrl's signature", () => { + test("cannot be called without a provider", () => { + expect(brokerReturnUrl.length).toBe(2); + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: the omission is the point + (brokerReturnUrl as any)(" "), + ).toThrow(); + }); +}); diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index a9ed288c1..8325fa0c5 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -45,16 +45,21 @@ describe("brokerSentence", () => { * unvalidated environment string, so `string` really is the strongest promise it can make and the * check belongs here rather than in the type. * - * Each refusal is asserted by the half of its sentence that only it says. Both name - * `OPENBOT_APP_URL`, because both are fixed there, so a test that asked only for the setting would - * pass just as well if the two branches collapsed into one — and they are two different mistakes: - * an address nobody built, and a configured one that cannot work. + * Each refusal is asserted by the half of its sentence that only it says, because they are two + * different mistakes — an address nobody built, and a configured one that cannot work — and a test + * that asked only for what both say would pass just as well if the branches collapsed into one. + * + * WHICH SENTENCE EACH READER GETS IS PINNED IN `broker-return-url-voice.test.ts` AND NOT HERE. This + * file is about which addresses are refused; that one is about whose vocabulary the refusal is + * written in — a person's, naming no vendor and no variable, and an operator's, naming both. */ describe("brokerReturnUrl", () => { test("refuses an address that was never built", () => { - expect(() => brokerReturnUrl("")).toThrow(/built no address/); - expect(() => brokerReturnUrl(" ")).toThrow(/built no address/); - expect(() => brokerReturnUrl("")).toThrow(BrokerReturnUrlError); + expect(() => brokerReturnUrl("", "composio")).toThrow(/built no address/); + expect(() => brokerReturnUrl(" ", "composio")).toThrow( + /built no address/, + ); + expect(() => brokerReturnUrl("", "composio")).toThrow(BrokerReturnUrlError); }); test("refuses an address no browser could come back through", () => { @@ -64,7 +69,9 @@ describe("brokerReturnUrl", () => { "/settings/connected-accounts/x", "javascript:alert(1)", ]) { - expect(() => brokerReturnUrl(unusable)).toThrow(/not a web address/); + expect(() => brokerReturnUrl(unusable, "composio")).toThrow( + /not a web address/, + ); } }); @@ -85,20 +92,24 @@ describe("brokerReturnUrl", () => { * that the message is safe to show them — so the address must be absent from it as a whole and * in its parts, and the value asked about has to be one no sentence here mentions for its own * reasons. + * + * THE SETTING IS NO LONGER ASKED OF THIS SENTENCE, because this is the one the ROUTE ANSWERS and + * the route is `requireUser`: `OPENBOT_APP_URL` is an operator's remedy, and the assertion that + * an operator is still given it lives on `operatorMessage` in the voice test. */ - test("says which setting fixes it, and never quotes the address", () => { + test("never quotes the address it refused", () => { const address = "openbot-tenant-42.internal.corp/settings/connected-accounts/9f3c"; let thrown: unknown; try { - brokerReturnUrl(address); + brokerReturnUrl(address, "composio"); } catch (error) { thrown = error; } const sentence = brokerSentence(thrown); - expect(sentence).toContain("OPENBOT_APP_URL"); + expect(sentence).not.toBeNull(); expect(sentence).not.toContain(address); expect(sentence).not.toContain("openbot-tenant-42.internal.corp"); expect(sentence).not.toContain("/settings/connected-accounts/9f3c"); @@ -106,11 +117,14 @@ describe("brokerReturnUrl", () => { test("hands back the address a configured deployment built", () => { expect( - brokerReturnUrl("https://openbot.test/settings/connected-accounts/x"), + brokerReturnUrl( + "https://openbot.test/settings/connected-accounts/x", + "composio", + ), ).toBe("https://openbot.test/settings/connected-accounts/x"); - expect(brokerReturnUrl("http://localhost:3001/admin/plugins/x")).toBe( - "http://localhost:3001/admin/plugins/x", - ); + expect( + brokerReturnUrl("http://localhost:3001/admin/plugins/x", "composio"), + ).toBe("http://localhost:3001/admin/plugins/x"); }); /** @@ -140,7 +154,7 @@ describe("brokerReturnUrl", () => { "https://openbot.test/settings/\tconnected-accounts/x", "https://openbot.test/settings\r/connected-accounts/x", ]) { - expect(brokerReturnUrl(disguised)).toBe(intended); + expect(brokerReturnUrl(disguised, "composio")).toBe(intended); } }); }); From 15b2a8d9c527723ababab6b7af9e918eb0c6f398 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:48:11 +0200 Subject: [PATCH 130/232] fix(app): look a notice code up in a map that holds only what was put in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crafted `?authorized=` on this deployment's own origin broke the Connected accounts screen. `AUTHORIZATION_NOTICES` was an object literal, so the lookup walked `Object.prototype`: `?authorized=toString` answered with a function, `constructor` with another, `__proto__` with an object. None of them is nullish, so the `?? null` that exists to mean "a code this build does not know draws nothing" never caught them, and `Record` told the compiler the case could not arise. The page then handed React whatever came back — an empty red alert for a function, and for `__proto__` "Objects are not valid as a React child", which took the whole screen down for whoever opened the link. Filtering three names would leave the declared type a lie. These maps are now `ReadonlyMap`s instead: `.get()` answers with what was put in the map and nothing inherited, and it returns `string | undefined`, so the `?? null` and `authorizationNoticeFor`'s `string | null` are true statements rather than claims the compiler takes on trust. The same shape, keyed by a string from outside this deployment, is fixed wherever it appears: - `AUTHORIZATION_NOTICES` and `MARKS` (connected-accounts/index.tsx), keyed by a search parameter and by a catalogue key. - `FUNCTION_FOR` (gallery/activity.tsx) and its server mirror `ACTIVITY_FUNCTION_FOR` (components/headless.ts), keyed by a model's `report` argument; a borrowed member passed the `functionName ?` guard and was handed to the grant check as the data function the component reads. - `EXPORTABLE` (plugins/google-drive-rest.ts), keyed by the mime type of a file somebody else uploaded and named; a borrowed member made an unreadable file look like an exportable editor format, skipping the refusal and sending a function to Google as the export format. - `FIXES` (intelligence-entitlement-check.ts), keyed by Intelligence's own error code; a borrowed member won the `||` and left `fix` a function where the advice sentence belongs. Red first in every case, against unfixed code, then green. Symbols: no symbol is added, removed or renamed. Four module-private constants and two exported ones change type from an object literal to `ReadonlyMap`. Call sites, all updated: `AUTHORIZATION_NOTICES` — `authorizationNoticeFor` in the same file and `app/tests/connected-accounts-authorized-notice.test.tsx` (the only importer; `Object.keys`/`Object.values` there become `.keys()`/ `.values()`); `MARKS` — `markFor`, same file, no other reader; `FUNCTION_FOR` — `ActivityReportCard` and the gallery entry's `reads`, same file; `ACTIVITY_FUNCTION_FOR` — one `reads`, same file; `EXPORTABLE` — one read in `read_file_content`; `FIXES` — one read in `entitlementWarning`. Deliberately left alone: `AUTH_ROUTE_EVENTS` in server/src/app.ts, which is the identical pattern on a caller-controlled request path and is being handled outside this change; and the maps keyed by this deployment's own closed sets (`DECISIONS`, `DISCOVERY_REASONS`, `PROVIDER_NAMES`, `RUN_LABEL`, `TRANSPORT_LABEL`, `ORDER`, `browser-activity`'s `labels`, already guarded by `Object.hasOwn`). The settings page's `?connected=`/`?authorized=` surviving a reload, noted by a reviewer on index.tsx:209, is untouched. The one import reordered in connected-accounts/index.tsx is the formatter's doing: the file did not pass `biome check` before it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- app/src/components/gallery/activity.tsx | 21 ++++-- .../settings/connected-accounts/index.tsx | 74 ++++++++++++++----- ...nected-accounts-authorized-notice.test.tsx | 45 ++++++++++- server/src/components/headless.ts | 16 ++-- server/src/intelligence-entitlement-check.ts | 31 +++++--- server/src/plugins/google-drive-rest.ts | 17 +++-- server/tests/google-drive-rest.test.ts | 24 ++++++ server/tests/headless-components.test.ts | 27 +++++++ .../intelligence-entitlement-check.test.ts | 14 ++++ 9 files changed, 218 insertions(+), 51 deletions(-) diff --git a/app/src/components/gallery/activity.tsx b/app/src/components/gallery/activity.tsx index e72d56731..e1fe05c38 100644 --- a/app/src/components/gallery/activity.tsx +++ b/app/src/components/gallery/activity.tsx @@ -33,11 +33,18 @@ export const ActivityReportProps = z.object({ type ActivityArgs = z.infer; -/** Which server-side function each report reads. Not the model's choice. */ -const FUNCTION_FOR: Record = { - activity: "botActivity", - refusals: "recentRefusals", -}; +/** + * Which server-side function each report reads. Not the model's choice. + * + * A Map because the key is a string somebody else chose: an object lookup answers `toString` or + * `constructor` with a member inherited from `Object.prototype`, which is truthy and so walks + * straight past the `functionName ?` guards below — handing a function to the grant check as the + * data function this component reads, and to `callComponentFunction` as a function name. + */ +const FUNCTION_FOR: ReadonlyMap = new Map([ + ["activity", "botActivity"], + ["refusals", "recentRefusals"], +]); type ActivityRow = { bot: string; actions: number }; type RefusalRow = { @@ -61,7 +68,7 @@ export function ActivityReportCard({ const conversation = useConversation(); const [state, setState] = useState({ status: "reading" }); - const functionName = report ? FUNCTION_FOR[report] : undefined; + const functionName = report ? FUNCTION_FOR.get(report) : undefined; useEffect(() => { // Nothing to read until the arguments have finished streaming in. @@ -284,7 +291,7 @@ export const GALLERY: GalleryComponent[] = [ "The report is on screen for the person, filled with figures read from this deployment. You were not given the figures.", reads: (args) => { const report = typeof args.report === "string" ? args.report : undefined; - const functionName = report ? FUNCTION_FOR[report] : undefined; + const functionName = report ? FUNCTION_FOR.get(report) : undefined; return functionName ? [functionName] : []; }, }, diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 1ca67985a..52ecbfd12 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -16,12 +16,12 @@ import { } from "@/components/layout/page-shell"; import { RowMark } from "@/components/layout/row-mark"; import { PluginLogo } from "@/components/plugins/plugin-logo"; +import { GithubSources } from "@/components/settings/github-sources"; import { LinkedAccountsSection } from "@/components/settings/linked-accounts"; import { linkedNotice, visibleLinkedNotice, } from "@/components/settings/linked-notice"; -import { GithubSources } from "@/components/settings/github-sources"; import { Button } from "@/components/ui/button"; import { InputGroup, @@ -96,44 +96,80 @@ export const Route = createFileRoute("/_authed/settings/connected-accounts/")({ * AN UNRECOGNISED CODE DRAWS NOTHING, deliberately. A code this build does not know is either * somebody probing the parameter or a deployment mid-upgrade, and a generic "something went wrong" * for both would be a sentence that tells a person nothing and an invitation to keep trying. + * + * A MAP, NOT AN OBJECT, BECAUSE THE KEY COMES OUT OF A URL. An object literal inherits from + * `Object.prototype`, so an object lookup answers `toString`, `constructor` and `__proto__` with + * members nobody put here — none of them nullish, so no `?? null` turns them back into "draws + * nothing", and `Record` says they cannot happen so the compiler never asks. What + * reached the page was a function (an alert with nothing in it) or, for `__proto__`, an object, + * which React refuses to draw: "Objects are not valid as a React child" took the whole Connected + * accounts screen down for whoever opened the link. A `Map` holds only what was put in it, so the + * unrecognised code and the borrowed member are once again the same case, by construction rather + * than by a list of names to keep out. */ -export const AUTHORIZATION_NOTICES: Record = { - "no-broker": +export const AUTHORIZATION_NOTICES: ReadonlyMap = new Map([ + [ + "no-broker", "This deployment is not set up to connect apps, so nothing was recorded. Ask an administrator to set it up.", - "not-supported": + ], + [ + "not-supported", "This deployment's apps are connected once for the whole app, with the Connect button on the app's page; they are not authorized one action at a time.", - "missing-handle": + ], + [ + "missing-handle", "That link is missing the part that says which authorization it finishes, so nothing was done. Start the authorization again from the app's page.", - "no-attempt": + ], + [ + "no-attempt", "There is no authorization in progress for you to finish — it may have expired, or it may have been started by somebody else or in another session. Start it again from the app's page.", - "vendor-unavailable": + ], + [ + "vendor-unavailable", "The app's vendor did not answer when this deployment tried to finish that authorization, so nothing was recorded. Try the link again in a moment.", - "not-your-authorization": + ], + [ + "not-your-authorization", "That link does not finish the authorization you have in progress, so nothing was recorded and it is still waiting. Finish it from the tab it was started in, or start it again from the app's page.", - "not-finished": + ], + [ + "not-finished", "That authorization has not finished yet, so nothing was recorded. Finish it at the app's vendor, then open the link again — it is still waiting.", - "authorization-failed": + ], + [ + "authorization-failed", "The app's vendor did not complete that authorization, so nothing was recorded. Start it again from the app's page.", - "context-changed": + ], + [ + "context-changed", "That app's account changed while the authorization was open, so nothing was recorded and anything granted for the old account was withdrawn. Start it again from the app's page.", - "not-recorded": + ], + [ + "not-recorded", "This deployment could not record that authorization, so none of it was saved. Start it again from the app's page, and tell an administrator if it persists.", -}; + ], +]); /** The sentence for a code the verifier sent back, or null for one this build does not know. */ export function authorizationNoticeFor( authorized: string | undefined, ): string | null { - return authorized ? (AUTHORIZATION_NOTICES[authorized] ?? null) : null; + return authorized ? (AUTHORIZATION_NOTICES.get(authorized) ?? null) : null; } /** The same marks the admin connector list uses: these are the same vendors seen from your side. */ -const MARKS: Record> = { - "google-drive": IconBrandGoogleDrive, - notion: IconBrandNotion, -}; +const MARKS: ReadonlyMap< + string, + React.ComponentType<{ className?: string }> +> = new Map([ + ["google-drive", IconBrandGoogleDrive], + ["notion", IconBrandNotion], +]); -const markFor = (key: string) => MARKS[key] ?? IconPlug; +/* A Map for the same reason the notices above are one: a lookup answers with what was put in it and + * nothing a plain object would have inherited, so a catalogue key called `toString` draws the + * fallback mark rather than handing React `Object.prototype.toString` to render as a component. */ +const markFor = (key: string) => MARKS.get(key) ?? IconPlug; /** * Whether a row came through a broker. A deployment only ever holds rows from one provider, so the diff --git a/app/tests/connected-accounts-authorized-notice.test.tsx b/app/tests/connected-accounts-authorized-notice.test.tsx index 734d4f149..1603c1ba6 100644 --- a/app/tests/connected-accounts-authorized-notice.test.tsx +++ b/app/tests/connected-accounts-authorized-notice.test.tsx @@ -25,7 +25,7 @@ import { * turns one of the two red: a new code with no sentence would otherwise draw nothing at all. */ test("every reason the verifier can send has a sentence here, and no others", () => { - expect(Object.keys(AUTHORIZATION_NOTICES)).toEqual([ + expect([...AUTHORIZATION_NOTICES.keys()]).toEqual([ "no-broker", "not-supported", "missing-handle", @@ -40,7 +40,7 @@ test("every reason the verifier can send has a sentence here, and no others", () }); test("each code reads as its own sentence, not as one generic apology", () => { - const sentences = Object.values(AUTHORIZATION_NOTICES); + const sentences = [...AUTHORIZATION_NOTICES.values()]; expect(new Set(sentences).size).toBe(sentences.length); for (const sentence of sentences) { // Long enough to say what happened and what to do about it, rather than an apology. @@ -50,7 +50,7 @@ test("each code reads as its own sentence, not as one generic apology", () => { test("a code is turned into the words for it", () => { expect(authorizationNoticeFor("no-attempt")).toBe( - AUTHORIZATION_NOTICES["no-attempt"], + AUTHORIZATION_NOTICES.get("no-attempt") as string, ); }); @@ -71,3 +71,42 @@ test("the notices are not reachable from the search parameter's own value", () = authorizationNoticeFor("That account could not be connected."), ).toBeNull(); }); + +/** + * AND A CODE BORROWED FROM `Object.prototype` DRAWS NOTHING EITHER, which is a different claim + * from the one above and was once false. + * + * `?authorized=toString` is not a code the verifier can send, but it is a member every plain + * object inherits. A lookup that walks the prototype chain answers it with a function — not + * nullish, so no `?? null` catches it — and the page then hands a function, or for `__proto__` an + * object, to React as the notice to draw: an empty red alert for `constructor`, and for `__proto__` + * "Objects are not valid as a React child", which takes the whole screen down. A crafted link on + * this deployment's own origin broke Connected accounts for whoever opened it. + */ +test.each([ + ["toString", "a function React draws as nothing it can read"], + ["constructor", "a function, drawn as an empty red alert"], + ["__proto__", "an object, which crashes the screen React draws it on"], + ["valueOf", "a function"], + ["hasOwnProperty", "a function"], +])( + "%s is a borrowed member, not a code, and draws nothing (%s)", + (borrowed) => { + expect(authorizationNoticeFor(borrowed)).toBeNull(); + }, +); + +/** The declared `string | null` holds for every value a URL can carry, not only the known codes. */ +test.each([ + "no-attempt", + "toString", + "constructor", + "__proto__", + "something-somebody-made-up", +])( + "what ?authorized=%s answers is a sentence or nothing, never a function or an object", + (value) => { + const notice = authorizationNoticeFor(value); + if (notice !== null) expect(typeof notice).toBe("string"); + }, +); diff --git a/server/src/components/headless.ts b/server/src/components/headless.ts index cc001e14c..626e6d401 100644 --- a/server/src/components/headless.ts +++ b/server/src/components/headless.ts @@ -220,11 +220,15 @@ const ActivityReportProps = z.object({ ), }); -/** Which server-side function each activity report reads. The same map `activity.tsx` holds. */ -const ACTIVITY_FUNCTION_FOR: Record = { - activity: "botActivity", - refusals: "recentRefusals", -}; +/** + * Which server-side function each activity report reads. The same map `activity.tsx` holds, and a + * Map there for the same reason: the key is a string somebody else chose, and an object lookup + * answers a borrowed `Object.prototype` member with something truthy that is no function name. + */ +const ACTIVITY_FUNCTION_FOR: ReadonlyMap = new Map([ + ["activity", "botActivity"], + ["refusals", "recentRefusals"], +]); export type HeadlessComponentSpec = { /** The tool name, the catalogue key, and the renderer's key in the app. */ @@ -318,7 +322,7 @@ export const HEADLESS_COMPONENTS: readonly HeadlessComponentSpec[] = [ reads: (args) => { const functionName = typeof args.report === "string" - ? ACTIVITY_FUNCTION_FOR[args.report] + ? ACTIVITY_FUNCTION_FOR.get(args.report) : undefined; return functionName ? [functionName] : []; }, diff --git a/server/src/intelligence-entitlement-check.ts b/server/src/intelligence-entitlement-check.ts index 265ed1a8c..cef575869 100644 --- a/server/src/intelligence-entitlement-check.ts +++ b/server/src/intelligence-entitlement-check.ts @@ -20,18 +20,29 @@ export type EntitlementWarning = { fix: string; }; -const FIXES: Record = { - RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED: +/** + * The codes this build has advice for. A Map, because the code is Intelligence's string rather than + * this deployment's: a plain object answers `toString` with an inherited function, which is truthy + * and so takes the place of the sentence the `||` below exists to supply. + */ +const FIXES: ReadonlyMap = new Map([ + [ + "RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED", "Intelligence has no valid licence. Give Intelligence's own deployment a COPILOTKIT_LICENSE_TOKEN " + - "(its Helm chart takes it as appApi.licenseSecret, or externalSecrets.secrets.license); setting it " + - "on OpenBot alone is not enough. Until then memory and other licensed features are refused.", - RUNTIME_ENTITLEMENTS_SELF_HOSTED_EXPIRED: + "(its Helm chart takes it as appApi.licenseSecret, or externalSecrets.secrets.license); setting it " + + "on OpenBot alone is not enough. Until then memory and other licensed features are refused.", + ], + [ + "RUNTIME_ENTITLEMENTS_SELF_HOSTED_EXPIRED", "Intelligence's licence has expired. Renew it and replace COPILOTKIT_LICENSE_TOKEN on Intelligence's " + - "deployment. Until then memory and other licensed features are refused.", - RUNTIME_ENTITLEMENTS_MANAGED_UNAVAILABLE: + "deployment. Until then memory and other licensed features are refused.", + ], + [ + "RUNTIME_ENTITLEMENTS_MANAGED_UNAVAILABLE", "Managed Intelligence could not resolve this project's entitlement just now. This is usually " + - "temporary; licensed features may be refused until it recovers.", -}; + "temporary; licensed features may be refused until it recovers.", + ], +]); /** The warning a response deserves, or null when Intelligence answers ready and active. */ export function entitlementWarning( @@ -52,7 +63,7 @@ export function entitlementWarning( ...(code ? { code } : {}), ...(response.error?.message ? { message: response.error.message } : {}), fix: - (code && FIXES[code]) || + (code && FIXES.get(code)) || "Intelligence did not confirm this deployment's entitlement, so licensed features such as memory may be refused.", }; } diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts index 59ad4f943..a57b3da0b 100644 --- a/server/src/plugins/google-drive-rest.ts +++ b/server/src/plugins/google-drive-rest.ts @@ -65,12 +65,17 @@ const SHARED_DRIVE_ITEMS = { * * A Doc has no bytes to download — `alt=media` refuses it — so it has to be exported. Anything not * in here is a real file and is fetched directly. + * + * A Map, because the type comes off a file somebody else uploaded and named. Asked of a plain + * object, `toString` is answered with `Object.prototype.toString`: truthy, so the file is taken for + * an editor format, the refusal below is skipped, and that function is sent to Google as the format + * to export to. */ -const EXPORTABLE: Record = { - "application/vnd.google-apps.document": "text/plain", - "application/vnd.google-apps.spreadsheet": "text/csv", - "application/vnd.google-apps.presentation": "text/plain", -}; +const EXPORTABLE: ReadonlyMap = new Map([ + ["application/vnd.google-apps.document", "text/plain"], + ["application/vnd.google-apps.spreadsheet", "text/csv"], + ["application/vnd.google-apps.presentation", "text/plain"], +]); /** * What this adapter offers, as the same shape a server would have answered `tools/list` with. @@ -478,7 +483,7 @@ export async function callTool( readId = targetId; } - const exportAs = file.mimeType ? EXPORTABLE[file.mimeType] : undefined; + const exportAs = file.mimeType ? EXPORTABLE.get(file.mimeType) : undefined; /* * A file whose bytes are not text is declined by name, not decoded and hoped for. diff --git a/server/tests/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts index 198928ce1..1370edf2c 100644 --- a/server/tests/google-drive-rest.test.ts +++ b/server/tests/google-drive-rest.test.ts @@ -360,6 +360,30 @@ describe("reading a file asks Drive what it is first", () => { // One call: the metadata lookup. No download followed it. expect(calls).toHaveLength(1); }); + + /* + * A type Google never defined is declined like any other unreadable one. + * + * Whoever uploaded the file chose the type string, so the export table is asked a question by + * somebody outside this deployment. Looked up in a plain object, `toString` is answered with + * `Object.prototype.toString` — truthy, so the file is treated as an exportable editor format: + * the refusal above is skipped and `?mimeType=function toString() { [native code] }` is sent to + * Google in its place. + */ + test("a made-up type borrowed from Object.prototype is not an exportable format", async () => { + for (const mimeType of ["toString", "constructor", "valueOf"]) { + const calls = stubFetch({ id: "odd1", name: "Odd", mimeType }); + + const result = await callTool(connection, "read_file_content", { + fileId: "odd1", + }); + + expect(result.isError).toBe(true); + expect(result.text).toContain(mimeType); + // One call: the metadata lookup. No export followed it. + expect(calls).toHaveLength(1); + } + }); }); /* diff --git a/server/tests/headless-components.test.ts b/server/tests/headless-components.test.ts index a267d5b7d..d4f6aff9b 100644 --- a/server/tests/headless-components.test.ts +++ b/server/tests/headless-components.test.ts @@ -91,6 +91,33 @@ describe("headless components mirror the compiled gallery", () => { } }); + /** + * The report name arrives from a model, so it is as much somebody else's string as a query + * parameter is, and the map it looks up must answer with what was put in it and nothing a plain + * object inherits. An object lookup answers `report: "toString"` with `Object.prototype.toString` + * — truthy, so it passes the `functionName ?` guard — and that function is then handed to the + * grant check as the data function this component reads, and to `callComponentFunction` as a + * function name. Both copies of the map, the app's and the server's mirror, are asserted here. + */ + test("a borrowed Object.prototype member is not a report and reads nothing", () => { + const activity = ACTIVITY.find( + (entry) => entry.name === "showActivityReport", + ); + const mirror = HEADLESS_COMPONENTS.find( + (spec) => spec.name === "showActivityReport", + ); + for (const report of [ + "toString", + "constructor", + "__proto__", + "valueOf", + "hasOwnProperty", + ]) { + expect(activity?.reads?.({ report })).toEqual([]); + expect(mirror?.reads?.({ report })).toEqual([]); + } + }); + test("the table's cross-field rule is enforced headlessly too", () => { const table = HEADLESS_COMPONENTS.find((spec) => spec.name === "showTable"); expect( diff --git a/server/tests/intelligence-entitlement-check.test.ts b/server/tests/intelligence-entitlement-check.test.ts index f053d409e..de63d1a0a 100644 --- a/server/tests/intelligence-entitlement-check.test.ts +++ b/server/tests/intelligence-entitlement-check.test.ts @@ -71,6 +71,20 @@ describe("what Intelligence's entitlement answer means for this deployment", () entitlementWarning(notReady("unavailable", "SOMETHING_NEW"))?.fix, ).toContain("memory"); }); + + /* + * The code is Intelligence's string, not ours, so a code borrowed from `Object.prototype` is as + * unknown as any other and deserves the same sentence. Looked up in a plain object, `toString` is + * answered with a function, which is truthy and so wins the `||` fallback — leaving `fix` a + * function where a sentence belongs, and a warning that says nothing wherever it is shown. + */ + test("a code borrowed from Object.prototype is unknown like any other", () => { + for (const code of ["toString", "constructor", "valueOf"]) { + const fix = entitlementWarning(notReady("unavailable", code))?.fix; + expect(typeof fix).toBe("string"); + expect(fix).toContain("memory"); + } + }); }); describe("reporting it at boot", () => { From 224c8e2e259b4f391d2a4f727065506d88fc5e48 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:49:24 +0200 Subject: [PATCH 131/232] fix(arcade): answer a mint this deployment could not record, and free the actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recordAuthorizationMint` ran unguarded after a SUCCESSFUL vendor mint. A transient local fault on that one UPDATE threw past the handler into a bodyless 500 and left the claim standing with a null `auth_id` — a row that can never complete, because the return leg matches on `auth_id`, and that still holds the actor until its fifteen-minute TTL. One database hiccup turned a successful authorization into a person locked out of retrying, told nothing, with a live flow at Arcade that nothing here recorded. The write is now guarded. The dead attempt is discarded — matched on `(actor_id, created_at)`, so it can never delete a newer one, and in its own `try`, so a cleanup that fails too cannot restore the bodyless 500 — the person is answered a sentence that says the authorization started at the vendor, that nothing was connected, and what to press, and is NOT sent to the consent screen behind a flow that could never be completed here. The fact the mint happened is carried on an `arcade-mint-not-recorded` console line holding the `ar_...` the vendor answered, so an operator can end that flow; the redirect url, which is the bearer capability, is not logged, and the error goes through `reasonWithoutStatement` as `oauth-connection-not-recorded` does. Discarding here is not the uncertain-mint branch turned around: that row is left standing because it may record a live flow, while this one provably records nothing — the id that would let it complete is the value that failed to write. Same-pattern audit of the whole of routes.ts found one other local write after an irreversible external effect with no guard: `forgetRule` after `removeServer` on `DELETE /servers/:id`. By then every account is withdrawn at the vendor and every row is gone, and a second Remove finds no app, so a fault retiring the leftover ask-before-write rule answered a completed removal with a bodyless 500. It is guarded, logged as `shared-rule-not-forgotten`, and still answered `ok`. Every other vendor-then-local sequence on this file (connect, confirm, recheck, disconnect, account-mode, the verify and oauth callback legs) is already inside a catch that authors an answer; those are untouched. Red-green: both branches were run against the unfixed route first. The mint one failed on `JSON Parse error: Unexpected identifier "Internal"` (Hono's bodyless 500) and on the attempt row surviving with `authId: null`; the removal one failed with `Expected: 200 / Received: 500`. No symbol added, renamed, resignatured or removed in `src`. The only signature change is the test-local `appAs` helper in server/tests/arcade-authorize-route.test.ts, which gains a fourth optional `override: Partial` argument. All 23 call sites are in that file: the 20 that existed pass three arguments or fewer and are unchanged, and the 3 new ones (lines 478, 500, 519) are the tests added here. Deliberately NOT changed: D7 (`recordAuthorizationMint`'s `(actor_id, created_at)` equality match) and D3 (`discardAuthorizationAttempt` throwing inside the mint catch at the uncertain/definite branch above) — both are other agents' findings, and the defect in that catch is left exactly as it was. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/routes.ts | 101 ++++++++++++++++++-- server/tests/arcade-authorize-route.test.ts | 80 +++++++++++++++- server/tests/shared-brokered-routes.test.ts | 51 ++++++++++ 3 files changed, 223 insertions(+), 9 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 95048893f..9727514f8 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -777,8 +777,32 @@ export function createPluginRoutes( ); return context.json({ error: refusal.error }, refusal.status); } - if (wasShared) - await shared?.modes.forgetRule(serverId, context.var.actor.id); + /* + * THE SAME SHAPE AS THE MINT'S RECORD-AFTER-THE-FACT BELOW, AND GUARDED FOR THE SAME REASON. + * `removeServer` has already returned, which means every account was withdrawn AT THE VENDOR + * and every row here is gone — none of it undoable, and none of it repeatable either: a second + * Remove finds no app. Unhandled, a fault tidying up the one leftover ask-before-write rule + * answered that completed removal with a bodyless 500, so the administrator was told the + * removal failed and had no press left to make. + * + * SO THE ANSWER STAYS `ok`, BECAUSE THE REMOVAL REALLY DID HAPPEN, and the leftover is named on + * the console instead: a revoked-rule row that outlives the app it guarded, which an + * administrator can retire from the rules screen and which grants nobody anything meanwhile. + */ + if (wasShared) { + try { + await shared?.modes.forgetRule(serverId, context.var.actor.id); + } catch (error) { + console.error( + JSON.stringify({ + type: "shared-rule-not-forgotten", + server: serverId, + note: "An app was removed, at the vendor and here, and its ask-before-write rule could not be retired afterwards. The administrator was answered ok, because the removal is done and cannot be repeated; the rule is still standing.", + error: reasonWithoutStatement(error), + }), + ); + } + } return context.json({ ok: true }); }); @@ -2644,11 +2668,74 @@ export function createPluginRoutes( ); } // A null id is recorded as NULL: an attempt holding none can never complete and simply expires. - await store.recordAuthorizationMint({ - actorId: context.var.actor.id, - createdAt: claim.attempt.createdAt, - authId: minted.authorizationId, - }); + try { + await store.recordAuthorizationMint({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + authId: minted.authorizationId, + }); + } catch (error) { + /* + * THE ONE WRITE ON THIS ROUTE THAT RUNS AFTER SOMETHING IRREVERSIBLE, AND IT USED TO BE THE + * ONLY CALL HERE WITH NO GUARD AT ALL. + * + * The mint SUCCEEDED: a flow is live at the vendor. Unhandled, a transient fault on this one + * local UPDATE — a statement timeout, a pool exhausted, a failover — threw past the handler + * into a BODYLESS 500, and left the claim standing with a NULL `auth_id`. That row is the + * worst of both halves. It can never complete, because the return leg matches the attempt on + * `auth_id` and a null matches no `flow_id` the browser can bring back; and it still HOLDS + * THE ACTOR, because `claimAuthorizationAttempt` refuses a second start until the row is + * fifteen minutes old. So one local hiccup turned a successful authorization into a person + * locked out of retrying, told nothing, with a live flow at Arcade that this deployment has + * no record of. + * + * THE ATTEMPT IS DISCARDED RATHER THAN LEFT TO EXPIRE, and this is NOT the uncertain-mint + * branch above turned around. There, the row is left standing because it may yet be the + * record of a live flow and deleting it would free the actor to run a second flow beside one + * that could still complete. Here the row provably records NOTHING: the id that would let it + * complete is exactly the value that failed to be written. Keeping it buys no safety and + * costs the person a quarter of an hour of refusals. Matched on `(actor_id, created_at)`, so + * a slow arrival here can never delete a newer attempt, and wrapped in its own `try` because + * the fault that broke the write above is likely to break this one too — a failed cleanup + * must not replace this refusal with the bodyless 500 it exists to prevent. + * + * AND THE MINT IS NOT LOST. The console line is what is left to carry it once the database + * has refused the row, and it holds the `ar_...` the vendor answered — the plain-text value + * this route was about to store — so an operator can find that flow at the vendor and end it. + * THE REDIRECT URL IS NOT LOGGED AND NEVER WILL BE: that is the bearer capability, and + * whoever opens it attaches the account. The error goes through + * {@link reasonWithoutStatement} for the reason `oauth-connection-not-recorded` gives: a + * `DrizzleQueryError`'s own message is the statement and every value bound to it. + */ + let freed = false; + try { + freed = await store.discardAuthorizationAttempt({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + }); + } catch { + freed = false; + } + console.error( + JSON.stringify({ + type: "arcade-mint-not-recorded", + server: serverId, + action, + authorizationId: minted.authorizationId, + attemptCleared: freed, + note: "An authorization was minted at the vendor and could not be written to its attempt. The person was told, was not sent to the consent screen, and the flow named here is live at the vendor with nothing here recording it.", + error: reasonWithoutStatement(error), + }), + ); + return context.json( + { + error: freed + ? "The app's vendor started that authorization, and this deployment could not record it — so it cannot be finished here and nothing was connected. Press Authorize again: the attempt was cleared, so starting over is safe." + : "The app's vendor started that authorization, and this deployment could not record it — so it cannot be finished here and nothing was connected. The attempt could not be cleared either; it expires within fifteen minutes, and starting over works after that.", + }, + 500, + ); + } return context.json({ authorizationUrl: minted.redirectUrl }); }); diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index 7296ab394..d2f747b79 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -18,7 +18,7 @@ import { mcpServers, mcpTools, pendingAuthorizations } from "../src/db/schema"; import type { BrokerProvider } from "../src/plugins/access"; import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginRoutes } from "../src/plugins/routes"; -import { createPluginStore } from "../src/plugins/store"; +import { createPluginStore, type PluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -80,6 +80,9 @@ function appAs( role: "admin" | "user", id: string, provider: BrokerProvider = "arcade", + // What this app's store does differently. One test makes the local write after a SUCCESSFUL mint + // fail, which is the only way to reach the route's record-after-the-fact branch. + override: Partial = {}, ) { const broker = provider === "arcade" ? arcadeBroker : composioBroker; const store = createPluginStore({ @@ -102,7 +105,7 @@ function appAs( return new Hono().route( "/api/plugins", createPluginRoutes( - store, + { ...store, ...override }, signedIn, async () => true, { @@ -458,6 +461,79 @@ describe("POST /servers/:id/authorize", () => { }); }); + describe("a mint that succeeded and could not be recorded", () => { + // The local UPDATE after the vendor said yes. `DrizzleQueryError`'s own message is the + // statement and its bound values, which is the shape this route must not pass on. + const failWrite = { + recordAuthorizationMint: async () => { + throw new Error( + 'Failed query: update "pending_authorizations" set "auth_id" = $1 -- params: ["ar_minted_1"]', + ); + }, + } as Partial; + + test("the person is told, is not sent to the consent screen, and the url does not travel", async () => { + const actor = person("unrecorded"); + const response = await authorize( + appAs("user", actor, "arcade", failWrite), + personalApp, + ); + // Not a bodyless 500: a sentence, and nothing of the vendor's answer in it. + expect(response.status).toBe(500); + const body = (await response.json()) as { + error?: string; + authorizationUrl?: string; + }; + expect(typeof body.error).toBe("string"); + expect(body.authorizationUrl).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain("bearer-capability"); + expect(JSON.stringify(body)).not.toContain("Failed query"); + // The mint really did happen at the vendor. + expect(mints).toHaveLength(1); + }); + + test("the actor is not blocked: the dead attempt is cleared and a second start mints again", async () => { + const actor = person("unrecorded-retry"); + expect( + ( + await authorize( + appAs("user", actor, "arcade", failWrite), + personalApp, + ) + ).status, + ).toBe(500); + // A row holding a null auth_id can never complete and would hold the actor for the TTL. + expect(await attemptsFor(actor)).toEqual([]); + mints = []; + nextAuthId = "ar_after_retry"; + const second = await authorize(appAs("user", actor), personalApp); + expect(second.status).toBe(200); + expect(mints).toHaveLength(1); + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ authId: "ar_after_retry", outcome: null }); + }); + + test("a failed clean-up still answers a sentence rather than throwing past the handler", async () => { + const actor = person("unrecorded-stuck"); + const response = await authorize( + appAs("user", actor, "arcade", { + ...failWrite, + discardAuthorizationAttempt: async () => { + throw new Error("Failed query: delete from pending_authorizations"); + }, + }), + personalApp, + ); + expect(response.status).toBe(500); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + expect(JSON.stringify(body)).not.toContain("Failed query"); + // Nothing cleared it, so the row stands and expires on its own. + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ authId: null, completedAt: null }); + }); + }); + test("two concurrent starts by one actor yield exactly one 200 and one 409", async () => { const actor = person("race"); // Hold the winner's mint open so the loser's claim runs while it is in flight. diff --git a/server/tests/shared-brokered-routes.test.ts b/server/tests/shared-brokered-routes.test.ts index 430e66a74..5c9ac7b94 100644 --- a/server/tests/shared-brokered-routes.test.ts +++ b/server/tests/shared-brokered-routes.test.ts @@ -28,6 +28,9 @@ const authorizedFor: string[] = []; const returnUrlsSeen: string[] = []; let connectWithFieldsCalls = 0; const revokedFor: string[] = []; +// Whether retiring the ask-before-write rule fails. One test makes it, to reach the removal +// route's clean-up branch — the removal itself has already happened at the vendor by then. +let revokeRuleThrows = false; const rules: { id: string; botId: string; @@ -97,6 +100,8 @@ function appAs(role: "admin" | "user", id = `${role}-${suite}`) { }, teamRules: async () => rules as never, revokeTeamRule: async (_by, ruleId) => { + if (revokeRuleThrows) + throw new Error("Failed query: update team_rules"); const rule = rules.find((row) => row.id === ruleId); if (rule) rule.revokedAt = new Date(); }, @@ -133,6 +138,7 @@ beforeEach(async () => { connectWithFieldsCalls = 0; revokedFor.length = 0; rules.length = 0; + revokeRuleThrows = false; await database.insert(mcpServers).values({ id: app, title: "Team GitHub", @@ -520,4 +526,49 @@ describe("removing a Shared app", () => { expect(response.status).toBe(200); expect(rules.filter((rule) => rule.revokedAt === null)).toEqual([]); }); + + /* + * THE CLEAN-UP RUNS AFTER SOMETHING IRREVERSIBLE. `removeServer` has already revoked every + * account AT THE VENDOR and deleted the rows, and a second Remove finds no app — so a fault + * retiring the leftover rule must not come back as the framework's bodyless 500 telling an + * administrator the removal failed. + */ + test("a rule that cannot be retired does not turn a completed removal into a bodyless 500", async () => { + await database + .update(mcpServers) + .set({ accountMode: "personal", sharedVendorUserId: null }) + .where(eq(mcpServers.id, app)); + await appAs("admin").request(`/api/plugins/servers/${app}/account-mode`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "shared", confirm: true }), + }); + expect(rules.filter((rule) => rule.revokedAt === null)).toHaveLength(1); + + revokeRuleThrows = true; + const said: string[] = []; + const realError = console.error; + console.error = (...args: unknown[]) => { + said.push(args.map(String).join(" ")); + }; + let response: Response; + try { + response = await appAs("admin").request(`/api/plugins/servers/${app}`, { + method: "DELETE", + }); + } finally { + console.error = realError; + } + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + // The removal really did happen, which is why it is answered as one. + expect( + await database.select().from(mcpServers).where(eq(mcpServers.id, app)), + ).toEqual([]); + const line = said.find((line) => + line.includes("shared-rule-not-forgotten"), + ); + expect(line).toBeDefined(); + expect(line).toContain(app); + }); }); From e095ea2c5ea0733a4bc3cfb613975cf1babbefe0 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:50:34 +0200 Subject: [PATCH 132/232] fix(plugins): remove a server at the broker that minted it, not the configured one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `removeServer` read three different answers to "which vendor is this row's" in one operation: the app slug off the ROW's provenance (through `accessFor`, which parses the url with the parser belonging to that column), the connection rows off the CONFIGURED `brokerProvider`, and the revocation off the ACTIVE broker. For a row provenanced at the other broker — a deployment that switched broker, a restore, a hand edit — the three disagreed: a non-null FOREIGN slug was handed to `revoke` and `deleteAuthConfig` at the wrong vendor, while the deletes beneath it matched no row at all. The removal then reported the connector gone, with every local consent row still standing and the right vendor never asked. The row's provenance now decides WHAT is cleared, and the configured provider decides only WHETHER a vendor is asked. The local half always runs — "removing an app leaves nobody holding brokered access to it" is about this deployment's own gate, whichever broker minted the row — so the connection rows of that row's own provider are read, deleted and audited. The vendor half (`resolveSiblings`, `revoke`, `deleteAuthConfig`) is skipped where the row's broker is not the configured one, because there is no client for it here; `vendorRevocationRequested` then says `false`, which is what that field exists to say and the same answer a deployment with no broker at all gets. The same-pattern audit of `store.ts` found one more, in `connectionTokenFor`: the `grantIsLocal` waiver — the ACTIVE broker's declaration that it checks per action at the vendor — was read about rows minted at the OTHER broker. On an Arcade deployment a left-over Composio app was therefore callable with no connection row of any kind, exempted by a per-action check at a vendor that has never heard of it. The waiver is now read only about that broker's own rows; a foreign row falls through to the local gate, which refuses. Every other site that meets both — `accountRefFor`, `requireServer`, `serverAddress`, `accountsOn`, `setAccountModeColumns`, `deploymentConnectionFor`, `listServers`, `addBrokeredApp` — either already guards with `provenance === brokerProvider` or reads the active provider throughout and never consults the column, and is unchanged. No symbol added, renamed, resignatured or removed: `rowProvider` and `rowBroker` are locals inside `removeServer`, and `brokeredProvenance` is an existing export of `./access` newly imported here (its other callers are unaffected). Not changed: `retireConnectionsFor`'s missing `resolveSiblings` (a separate finding, owned elsewhere), which this change does not touch. Tests: both defects were watched to fail first — the removal asking Composio to delete an auth config named `Gmail-…` and leaving two `arcade` rows standing, and the foreign-provenance call resolving instead of refusing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/store.ts | 123 ++++++++-- .../gate-foreign-provenance-call.test.ts | 151 +++++++++++++ .../remove-server-foreign-provenance.test.ts | 210 ++++++++++++++++++ 3 files changed, 463 insertions(+), 21 deletions(-) create mode 100644 server/tests/gate-foreign-provenance-call.test.ts create mode 100644 server/tests/remove-server-foreign-provenance.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index d075e1415..b943a71ad 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -50,6 +50,7 @@ import { accessFor, BROKERED_PROFILE, type BrokerProvider, + brokeredProvenance, type ServerAccess, ServerUnresolvableError, toolkitOfFor, @@ -1872,8 +1873,24 @@ export function createPluginStore(options: PluginStoreOptions) { * derived by {@link accountRefFor} from the session's actor and the app's own mode, Personal * or Shared, exactly as before. The model cannot name an identity, nothing about whose * account a call runs in changes here, and the Shared case still goes out as the deployment. + * + * AND THE PROPERTY IS ONLY EVER READ ABOUT THAT BROKER'S OWN ROWS, which is not a vendor-name + * comparison sneaking back in — it is the question of WHOSE declaration this is. + * + * CRITERION. A brokered row is governed by the broker it was minted at. Where that is not the + * configured one, no declaration of the configured broker's applies to it, and the local gate + * stands — which refuses, because a row of the other broker's can have no connection row here. + * + * REASON. `access.transport` is the row's provenance (see `accessFor`) and `broker` is whatever + * this deployment is configured with, and the two are not always the same vendor: a deployment + * that has switched broker still holds the other's rows. Read off the active broker alone, the + * waiver crossed over to them — on an Arcade deployment, a left-over Composio app was called + * with no connection row of any kind, exempted by a per-action check at a vendor that has never + * heard of it, on a deployment that cannot connect, check or disconnect it at all. The + * `grantIsLocal` argument is about where THIS broker's grants live; it says nothing about + * somebody else's. */ - if (broker && !broker.grantIsLocal) + if (broker && !broker.grantIsLocal && access.transport === brokerProvider) return { vendorUserId: resolved.ref.vendorUserId }; const [connected] = await database .select({ vendorUserId: brokeredConnections.vendorUserId }) @@ -3949,7 +3966,9 @@ export function createPluginStore(options: PluginStoreOptions) { * KEYED ON THE APP THE URL NAMES, exactly as `connectionTokenFor` keys the gate. `row.id` is a * display key and nothing holds it equal to the slug in the url, so a delete by id would clear * some other app's connections, or none, for the very row shape that gate already refuses to - * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. + * trust. The slug is read with {@link toolkitOfFor} — the one per-provider parser `accessFor` + * itself reads it with — so this cannot drift from what the gate resolves; see the comment on + * `rowProvider` below for why the PROVIDER is read beside it rather than the slug alone. * * ASKED WITH NO ENTRY, deliberately, and that is not the entry-wins order being dodged. An * entry can only ever SUPPRESS this answer — `accessFor` returns a null toolkit for every row @@ -3987,9 +4006,52 @@ export function createPluginStore(options: PluginStoreOptions) { * deployment holds at Composio, not an account — while a live account whose config has * already been deleted is access that nothing left here can end. */ - const toolkit = existing ? accessFor(existing, null).toolkit : null; + /* + * AND ALL OF IT IS ABOUT THE BROKER THE ROW WAS MINTED AT, which is not always the one this + * deployment is configured with. + * + * CRITERION. One removal names ONE vendor. The app slug, the connection rows it clears and + * the account it ends are all that row's own broker's, and a vendor is asked only where it is + * the one this deployment can reach. + * + * REASON. These three were read three different ways in one operation: the slug off the ROW's + * provenance (through `accessFor`, which parses the url with the parser belonging to whatever + * that column says), the connection rows off the CONFIGURED `brokerProvider`, and the + * revocation off the ACTIVE broker. A deployment that has switched broker — or a restore, or a + * hand edit — holds rows whose provenance is the other vendor, and for one of those the three + * disagreed: a non-null FOREIGN slug went to `revoke` and `deleteAuthConfig` at the wrong + * vendor, naming one of its apps by a name that means something else there, while the deletes + * beneath matched no row at all. The removal then reported the connector gone, with every + * local consent row still standing and the right vendor never asked. + * + * SO THE ROW'S PROVENANCE DECIDES WHAT IS CLEARED, AND THE CONFIGURED PROVIDER DECIDES ONLY + * WHETHER A VENDOR IS ASKED. The local half always runs: the criterion two comments up — + * removing an app leaves nobody holding brokered access to it — is about this deployment's own + * gate, and it holds whichever broker minted the row. The vendor half is skipped where the + * row's broker is not the configured one, because there is no client for it here and no + * credential to reach it with; `vendorRevocationRequested` then says `false` on every trail + * row, which is exactly what that field is for and the same answer a deployment with no broker + * at all gets. An account left standing at a vendor this deployment can no longer reach is not + * a thing a removal can end, and saying so is better than asking the wrong vendor to. + * + * The parse is {@link toolkitOfFor} with the ROW's provider rather than `accessFor`, because + * what this needs is the pair — which broker, and which app at it — and `accessFor` answers + * only the second. It is the same answer for the same rows: `accessFor` reads the slug with + * the very same per-provider parser, under the same brokered-provenance test, and is asked + * with no entry here for the reason the comment above gives. + */ + const rowProvider = + existing && brokeredProvenance(existing.provenance) + ? existing.provenance + : null; + const toolkit = + existing && rowProvider + ? toolkitOfFor(rowProvider, existing.url) + : null; + /** The broker to ask, which is none at all where the row is the other vendor's. */ + const rowBroker = rowProvider === brokerProvider ? broker : null; - if (toolkit) { + if (rowProvider && toolkit) { /* * Read before anything is deleted, because the revokes below need the people and the rows * are where the people are. Sorted, so two removals of the same app revoke in the same @@ -4004,7 +4066,8 @@ export function createPluginStore(options: PluginStoreOptions) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, brokerProvider), + // The row's own broker, not the configured one: see the comment above `rowProvider`. + eq(brokeredConnections.provider, rowProvider), eq(brokeredConnections.app, toolkit), ), ) @@ -4065,27 +4128,38 @@ export function createPluginStore(options: PluginStoreOptions) { */ const authProviders = new Map>(); for (const connection of connected) { + /* + * ASKED OF `rowBroker` AND NOT OF `broker`, both here and at the revoke below. + * {@link resolveSiblings} resolves against the CONFIGURED provider's rows and the active + * broker's `authProviderOf`, so asking it about a foreign row's app would be the same + * wrong-vendor question the revoke was asking. No broker for this row means nothing was + * asked, which is the answer the empty set and the `false` beside it already carry. + */ endedBesides.push( - await resolveSiblings( - { toolkit, account: accountOf(connection) }, - authProviders, - ), + rowBroker + ? await resolveSiblings( + { toolkit, account: accountOf(connection) }, + authProviders, + ) + : { siblings: [], unanswered: 0 }, ); vendorRevocationRequested.push( - broker - ? await broker.revoke({ account: accountOf(connection), toolkit }) + rowBroker + ? await rowBroker.revoke({ + account: accountOf(connection), + toolkit, + }) : false, ); } - await database - .delete(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, brokerProvider), - eq(brokeredConnections.app, toolkit), - ), - ); + await database.delete(brokeredConnections).where( + and( + // The row's own broker, as the read above. + eq(brokeredConnections.provider, rowProvider), + eq(brokeredConnections.app, toolkit), + ), + ); for (const [index, connection] of connected.entries()) { await recordAuditEvent(auditStore, { @@ -4148,6 +4222,11 @@ export function createPluginStore(options: PluginStoreOptions) { * shown to have ended anything, so a sibling's row is left standing rather than claimed * gone. It sits after the revokes above for the same reason the asked app's rows do: an * exception from any of them left every row standing. + * + * AND IT IS REACHED ONLY FOR THE CONFIGURED PROVIDER'S OWN ROWS, which is what keeps the + * `accountRow` delete below — keyed on `brokerProvider` — about the same broker as the + * siblings it deletes: `true` here requires `rowBroker`, and there is no `rowBroker` for a + * row the other broker minted. */ for (const [index, connection] of connected.entries()) { const { siblings, unanswered } = endedBesides[index] ?? { @@ -4181,8 +4260,10 @@ export function createPluginStore(options: PluginStoreOptions) { } // Last of all, for the reason above, and skipped entirely on a deployment with no - // broker to have made one. - if (broker) await broker.deleteAuthConfig(toolkit); + // broker to have made one — or, for a row of the other broker's, no broker of ITS that + // this deployment can reach. Nothing here holds an auth config at a vendor it is not + // configured for, and this slug is not a name that vendor's apps go by. + if (rowBroker) await rowBroker.deleteAuthConfig(toolkit); } const released = await database.transaction(async (transaction) => { diff --git a/server/tests/gate-foreign-provenance-call.test.ts b/server/tests/gate-foreign-provenance-call.test.ts new file mode 100644 index 000000000..602d11e64 --- /dev/null +++ b/server/tests/gate-foreign-provenance-call.test.ts @@ -0,0 +1,151 @@ +// server/tests/gate-foreign-provenance-call.test.ts +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { + agents, + brokeredConnections, + mcpServers, + mcpTools, +} from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { useComposioClient } from "../src/plugins/composio"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/* + * THE EXEMPTION BELONGS TO THE BROKER THAT HOLDS THE GRANT, NOT TO WHICHEVER ONE IS CONFIGURED. + * + * `connectionTokenFor` waives the local connection row where the ACTIVE broker declares the grant + * is the vendor's — Arcade checks per action, per call, at the vendor, so a local row would be a + * weaker second copy of that check. A row minted at the OTHER broker is not covered by that + * promise: nothing checks it at Arcade, because it is not an Arcade app. Read off the active + * broker, the waiver crossed over to it and let a Composio app be called with no consent row of + * any kind — on a deployment that cannot connect, check or disconnect that app at all. + */ +const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +afterAll(async () => { + await database.$client.close(); +}); + +const suite = randomUUID().slice(0, 8); +const foreign = `left-over-gh-${suite}`; +const bot = `bot-${suite}`; +const admin = `admin-${suite}`; +const asker = `asker-${suite}`; + +/** Arcade's shape: the grant is the vendor's, so no local row is demanded of its own apps. */ +const brokerStub: ConnectedAppBroker = { + grantIsLocal: false, + authorizations: null, + authProviderOf: async () => null, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme-bot", +}; + +const store = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker: brokerStub, + deploymentId: `acme-${suite}`, + sharedUse: async () => ({ allowed: true }), +}); + +/** The ids the Composio transport was handed, which is where a call that went out is visible. */ +function recordingTransport(sentAs: string[]) { + useComposioClient({ + listActions: async () => [], + execute: async ({ userId }) => { + sentAs.push(userId); + return { successful: true, data: {}, error: null } as never; + }, + }); +} + +async function clean() { + await database + .delete(brokeredConnections) + .where(eq(brokeredConnections.app, foreign)); + await database.delete(mcpServers).where(eq(mcpServers.id, foreign)); + await database.delete(agents).where(eq(agents.id, bot)); +} + +beforeEach(async () => { + await clean(); + await database + .insert(agents) + .values({ id: bot, name: "Helper", type: "built_in", configuration: {} }); +}); +afterEach(async () => { + useComposioClient(null); + await clean(); +}); + +describe("a brokered row minted at the other broker", () => { + test("is not waved through by the configured broker's vendor-held grant", async () => { + const sentAs: string[] = []; + recordingTransport(sentAs); + // A row left behind by a deployment that used to broker through Composio. Nothing here can + // connect it, check it or end it: this deployment's broker is Arcade. + await database.insert(mcpServers).values({ + id: foreign, + title: "Left-over GitHub", + vendor: "Composio", + url: `composio://${foreign}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: "personal", + }); + await database.insert(mcpTools).values({ + serverId: foreign, + name: "GITHUB_LIST_ISSUES", + description: "List issues.", + effect: "read", + version: "1", + }); + await store.grant( + "mcp", + `${foreign}/GITHUB_LIST_ISSUES`, + bot, + `${admin}@example.test`, + ); + + await expect( + store.callTool({ + ref: `${foreign}/GITHUB_LIST_ISSUES`, + args: {}, + botId: bot, + actorId: asker, + }), + ).rejects.toThrow( + "You have not connected your Left-over GitHub account. Connect it in Settings and ask again.", + ); + expect(sentAs).toEqual([]); + }); +}); diff --git a/server/tests/remove-server-foreign-provenance.test.ts b/server/tests/remove-server-foreign-provenance.test.ts new file mode 100644 index 000000000..4fa14eb11 --- /dev/null +++ b/server/tests/remove-server-foreign-provenance.test.ts @@ -0,0 +1,210 @@ +// server/tests/remove-server-foreign-provenance.test.ts +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { auditEvents, brokeredConnections, mcpServers } from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/* + * WHAT A ROW LEFT BEHIND BY THE OTHER BROKER IS REMOVED AS. + * + * `mcp_servers.provenance` records which broker a row was minted at, and a deployment that has + * switched broker — or a restore, or a hand edit — holds rows whose provenance is NOT the + * configured provider. `removeServer` read three different answers to "which vendor is this": the + * app slug off the ROW's provenance, the connection rows off the CONFIGURED provider, and the + * revocation off the ACTIVE broker. So a foreign row's slug was handed to the wrong vendor while + * the deletes beneath it matched nothing, and the removal reported success either way. + * + * SCOPING. `audit_events` is append-only and `brokered_connections` is long-lived, so every + * assertion is keyed on names this file alone writes. + */ +const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +afterAll(async () => { + await database.$client.close(); +}); + +const suite = randomUUID().slice(0, 8); +const apps: string[] = []; +let n = 0; + +function run() { + n += 1; + const tag = `${suite}${n}`; + return { app: `Gmail-${tag}`, person: `person-${tag}` }; +} + +/** Everything the broker was asked, by name, so "which vendor was asked" is an assertion. */ +function recorder() { + const asked: { revoked: string[]; configsDeleted: string[] } = { + revoked: [], + configsDeleted: [], + }; + const broker: ConnectedAppBroker = { + grantIsLocal: true, + authorizations: { + status: async () => { + throw new Error("nothing here polls an authorization"); + }, + confirmReturn: async () => { + throw new Error("nothing here confirms a return leg"); + }, + }, + authProviderOf: async (toolkit) => toolkit, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async (toolkit) => { + asked.configsDeleted.push(toolkit); + }, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: "ar_1", + }), + isConnected: async () => true, + revoke: async (input) => { + asked.revoked.push(input.toolkit); + return true; + }, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme", + }; + return { asked, broker }; +} + +/** A store whose configured provider is Composio: the only vendor it can reach. */ +function composioStore(broker: ConnectedAppBroker) { + return createPluginStore({ + brokerProvider: "composio", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, + deploymentId: "acme", + sharedUse: async () => ({ allowed: true }), + }); +} + +async function server(id: string, provenance: string, url: string) { + apps.push(id); + await database.insert(mcpServers).values({ + id, + title: id, + vendor: provenance === "arcade" ? "Arcade" : "Composio", + url, + provenance, + authScheme: "OAUTH2", + accountMode: "personal", + }); +} + +async function hold(provider: string, app: string, userId: string) { + apps.push(app); + await database.insert(brokeredConnections).values({ + provider, + app, + holder: "person", + userId, + vendorUserId: userId, + }); +} + +const rowsFor = async (names: string[]) => + ( + await database + .select({ + provider: brokeredConnections.provider, + app: brokeredConnections.app, + }) + .from(brokeredConnections) + .where(inArray(brokeredConnections.app, names)) + .orderBy(asc(brokeredConnections.app)) + ).map((row) => `${row.provider}:${row.app}`); + +const trailFor = (names: string[]) => + database + .select({ + app: auditEvents.targetId, + payload: sql>`payload`, + }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_disconnected"), + inArray(auditEvents.targetId, names), + ), + ) + .orderBy(asc(auditEvents.targetId)); + +afterEach(async () => { + if (apps.length) { + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, apps)); + await database.delete(mcpServers).where(inArray(mcpServers.id, apps)); + } + apps.length = 0; +}); + +describe("removing a server whose provenance is not the configured provider", () => { + test("asks no vendor about it and still takes that broker's own rows away", async () => { + const t = run(); + const { asked, broker } = recorder(); + await server(t.app, "arcade", `arcade://${t.app}`); + await hold("arcade", t.app, t.person); + await hold("arcade", t.app, `${t.person}-2`); + + await composioStore(broker).removeServer(t.app, "admin"); + + // The configured vendor is Composio and this app is not one of its: it is asked nothing at all. + expect(asked.revoked).toEqual([]); + expect(asked.configsDeleted).toEqual([]); + // And the rows the row's OWN broker left behind are gone, rather than standing under a + // removal that reported success. + expect(await rowsFor([t.app])).toEqual([]); + // Each is written down as ended with nothing claimed of a vendor nobody could ask. + const trail = await trailFor([t.app]); + expect(trail.length).toBe(2); + for (const event of trail) { + expect(event.payload).toMatchObject({ + reason: "mcp_server_removed", + vendorRevocationRequested: false, + }); + } + // The server row itself goes, as it always did. + expect( + await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, t.app)), + ).toEqual([]); + }); + + test("a row of the configured provider's own is still revoked at the vendor", async () => { + const t = run(); + const slug = t.app.toLowerCase(); + const { asked, broker } = recorder(); + await server(slug, "composio", `composio://${slug}`); + await hold("composio", slug, t.person); + + await composioStore(broker).removeServer(slug, "admin"); + + expect(asked.revoked).toEqual([slug]); + expect(asked.configsDeleted).toEqual([slug]); + expect(await rowsFor([slug])).toEqual([]); + const trail = await trailFor([slug]); + expect(trail.length).toBe(1); + expect(trail[0]?.payload).toMatchObject({ + reason: "mcp_server_removed", + vendorRevocationRequested: true, + }); + }); +}); From 9c7dab8925e3ec7c59ef609bb42c6a06e25f2dfe Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:52:14 +0200 Subject: [PATCH 133/232] fix(plugins): name the broker this deployment actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen sentences in `routes.ts` and three in `store.ts` spelled the literal "Composio" into prose a reader acts on. On an Arcade deployment every one of them named a vendor this deployment holds no key for and never dials, and sent the reader to check a key that does not exist. Each now reads the ACTIVE provider's name off `BROKERED_PROFILE`, which is where a provider's vocabulary is already written down once: - `routes.ts` gains one `activeVendor`, derived from `activeProvider` — the same binding the brokered reads parse rows with, so the vendor a sentence names and the vendor a route reached cannot come apart. It feeds the remove tail, the enable tail, the six key-app connect refusals, the unreadable-scheme refusal, the no-app-URL refusal, the consent tail, and the confirm, re-check and disconnect tails — plus the two `console.error` notes those two tails write. - `store.ts` reads `BROKERED_PROFILE[brokerProvider].vendor` inline, as `addBrokeredApp` already does, in the brokered-credential invariant, the brokered-name collision an administrator meets on Add by URL, and the retry a person is told to make after a re-check that could not be made. Deliberately unchanged: the `Decides<>` roster literals in both files (type-only and erased, never read by anybody), and `probeBrokeredConnection`'s own sentence, which says the only connection check this deployment knows how to make is Composio's — that names the implementation gap and is true on every deployment. Call sites: `activeVendor` is a new local constant inside `createPluginRoutes`, read only by that function's own handlers; nothing is exported, renamed, removed or re-signatured, so there are no call sites outside the two files. Red-green: `server/tests/routes-vendor-vocabulary.test.ts` drives all fourteen route refusals on both providers — fourteen Arcade cases failed against the unfixed routes ("Composio would not say whether this account is connected…"), all 42 pass now, including the assertion that the two deployments' sentences differ in nothing but the vendor's name. `server/tests/store-vendor-vocabulary.test.ts` does the same for the three store sentences, each watched red first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/routes.ts | 53 ++- server/src/plugins/store.ts | 6 +- server/tests/routes-vendor-vocabulary.test.ts | 319 ++++++++++++++++++ server/tests/store-vendor-vocabulary.test.ts | 259 ++++++++++++++ 4 files changed, 616 insertions(+), 21 deletions(-) create mode 100644 server/tests/routes-vendor-vocabulary.test.ts create mode 100644 server/tests/store-vendor-vocabulary.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 95048893f..46b09207b 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -5,8 +5,8 @@ import { PERSON_INITIATOR } from "../audit"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; import { reasonWithoutStatement } from "../db/query-failure"; -import type { AccountModeSwitch } from "./account-mode"; import { BROKERED_PROFILE, type BrokerProvider, toolkitOfFor } from "./access"; +import type { AccountModeSwitch } from "./account-mode"; import { normalizeArcadeError } from "./arcade-adapter"; import { type AuthorizationFlow, @@ -345,6 +345,24 @@ export function createPluginRoutes( */ const activeProvider: BrokerProvider = composio?.provider ?? "composio"; + /** + * The ACTIVE broker's name, as the sentences on this layer spell it. + * + * ONE READING FOR EVERY SENTENCE, AND NEVER A LITERAL. Every refusal below used to spell + * "Composio" into the prose — the app that would not be removed, the app that would not be + * enabled, the six ways a key app's connect fails, the consent with nowhere to send anybody, and + * the confirm, re-check and disconnect tails. On an Arcade deployment each of them named a vendor + * this deployment holds no key for and never dials, and sent the reader to check a key that does + * not exist. The name comes off {@link activeProvider}'s profile, which is also what the brokered + * reads below parse with, so the vendor a sentence names and the vendor a route actually reached + * cannot come apart. + * + * THE PROFILE RATHER THAN A TERNARY, because {@link BROKERED_PROFILE} is where a provider's + * vocabulary is written down once — a second spelling here is the second home the record exists + * to prevent, and a provider added to it without being added here would typecheck. + */ + const activeVendor = BROKERED_PROFILE[activeProvider].vendor; + /** * Which app a brokered row is, read by the parser belonging to the broker this deployment runs. * @@ -765,7 +783,7 @@ export function createPluginRoutes( JSON.stringify({ type: "mcp-server-not-removed", server: serverId, - note: "Removing a server failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence, and the server row is still there.", + note: `Removing a server failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence, and the server row is still there.`, error: reasonWithoutStatement(error), }), ); @@ -773,7 +791,7 @@ export function createPluginRoutes( const refusal = brokerRefusal( error, - "Removing this app did not finish, and neither this deployment nor Composio said why, so the app is still here rather than gone with grants of its own left live at the vendor. Press Remove again: every account is withdrawn at Composio before anything here is deleted, so repeating it is safe and asks only for what is left. Check this deployment's Composio key if it persists.", + `Removing this app did not finish, and neither this deployment nor ${activeVendor} said why, so the app is still here rather than gone with grants of its own left live at the vendor. Press Remove again: every account is withdrawn at ${activeVendor} before anything here is deleted, so repeating it is safe and asks only for what is left. Check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -1220,7 +1238,7 @@ export function createPluginRoutes( JSON.stringify({ type: "composio-app-not-enabled", slug: app.slug, - note: "Enabling a brokered app failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence.", + note: `Enabling a brokered app failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence.`, error: reasonWithoutStatement(error), }), ); @@ -1228,7 +1246,7 @@ export function createPluginRoutes( const refusal = brokerRefusal( error, - `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, + `${app.name} could not be enabled, and ${activeVendor} said nothing about why. Try again, and check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -1544,7 +1562,7 @@ export function createPluginRoutes( } catch (error) { const refusal = brokerRefusal( error, - `Composio would not say what ${row.title} asks for, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + `${activeVendor} would not say what ${row.title} asks for, and said nothing about why. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -1574,7 +1592,7 @@ export function createPluginRoutes( if (published.length === 0) { return context.json( { - error: `${row.title} publishes no boxes to fill in, so there is nothing you could type that would reach it and no account was made. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, + error: `${row.title} publishes no boxes to fill in, so there is nothing you could type that would reach it and no account was made. An administrator has to look at how ${row.title} is set up at ${activeVendor}; removing it on the Plugins page and adding it again is what records the way ${activeVendor} connects it now.`, }, 400, ); @@ -1652,7 +1670,7 @@ export function createPluginRoutes( const optional = published.map((field) => field.name).join(", "); return context.json( { - error: `${row.title} publishes no box it says has to be filled in — only ${optional} — so nothing you could type would be a credential it insists on, and no account was made. An app that genuinely needs none is one Composio publishes as needing no authentication, and ${row.title} is recorded here as an app whose secret you type. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, + error: `${row.title} publishes no box it says has to be filled in — only ${optional} — so nothing you could type would be a credential it insists on, and no account was made. An app that genuinely needs none is one ${activeVendor} publishes as needing no authentication, and ${row.title} is recorded here as an app whose secret you type. An administrator has to look at how ${row.title} is set up at ${activeVendor}; removing it on the Plugins page and adding it again is what records the way ${activeVendor} connects it now.`, }, 400, ); @@ -1718,7 +1736,7 @@ export function createPluginRoutes( */ return context.json( { - error: `That is not the form ${row.title} publishes, so nothing was sent to Composio. Press Connect again to draw it from what the app asks for now, and fill in the boxes it shows; each one holds text.`, + error: `That is not the form ${row.title} publishes, so nothing was sent to ${activeVendor}. Press Connect again to draw it from what the app asks for now, and fill in the boxes it shows; each one holds text.`, }, 400, ); @@ -1766,7 +1784,7 @@ export function createPluginRoutes( if (missing.length > 0) { return context.json( { - error: `${row.title} cannot be connected without ${missing.join(", ")}, so nothing was sent to Composio. Fill in every box the form marks required — a box holding only spaces is an empty one — and press Connect again.`, + error: `${row.title} cannot be connected without ${missing.join(", ")}, so nothing was sent to ${activeVendor}. Fill in every box the form marks required — a box holding only spaces is an empty one — and press Connect again.`, }, 400, ); @@ -1807,7 +1825,7 @@ export function createPluginRoutes( } const refusal = brokerRefusal( error, - `${row.title} could not be connected with what you entered, and Composio said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + `${row.title} could not be connected with what you entered, and ${activeVendor} said nothing about why. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -1834,7 +1852,7 @@ export function createPluginRoutes( if (kind === "unreadable") { return context.json( { - error: `This deployment cannot tell how ${row.title} connects, so nothing was sent to Composio and no account was made. An administrator has to remove it on the Plugins page and add it again, which records the way Composio connects it now.`, + error: `This deployment cannot tell how ${row.title} connects, so nothing was sent to ${activeVendor} and no account was made. An administrator has to remove it on the Plugins page and add it again, which records the way ${activeVendor} connects it now.`, }, 400, ); @@ -1868,8 +1886,7 @@ export function createPluginRoutes( if (!connect?.appUrl) { return context.json( { - error: - "This deployment has no app URL configured, so Composio would have nowhere to send you back to. Set OPENBOT_APP_URL.", + error: `This deployment has no app URL configured, so ${activeVendor} would have nowhere to send you back to. Set OPENBOT_APP_URL.`, }, 503, ); @@ -1932,7 +1949,7 @@ export function createPluginRoutes( } catch (error) { const refusal = brokerRefusal( error, - `Composio would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + `${activeVendor} would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -2266,7 +2283,7 @@ export function createPluginRoutes( */ const refusal = brokerRefusal( error, - "Composio would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's Composio key if it persists.", + `${activeVendor} would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -2354,7 +2371,7 @@ export function createPluginRoutes( */ const refusal = brokerRefusal( error, - "Composio would not say whether this connection still works, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Press Re-check again, and ask an administrator to check this deployment's Composio key if it persists.", + `${activeVendor} would not say whether this connection still works, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Press Re-check again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -2421,7 +2438,7 @@ export function createPluginRoutes( */ const refusal = brokerRefusal( error, - "Composio would not end this account, and gave no reason. Press Disconnect again: the revoke at Composio runs before anything here is deleted, so repeating it is safe and is the whole recovery. Ask an administrator to check this deployment's Composio key if it persists.", + `${activeVendor} would not end this account, and gave no reason. Press Disconnect again: the revoke at ${activeVendor} runs before anything here is deleted, so repeating it is safe and is the whole recovery. Ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index d075e1415..76b09a4d5 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1755,7 +1755,7 @@ export function createPluginStore(options: PluginStoreOptions) { */ if (!access.toolkit) { throw new PluginInvariantError( - `${row.id} resolves to a brokered credential with no Composio app in its url.`, + `${row.id} resolves to a brokered credential with no ${BROKERED_PROFILE[brokerProvider].vendor} app in its url.`, ); } @@ -2757,7 +2757,7 @@ export function createPluginStore(options: PluginStoreOptions) { if (!existing) return; if (accessFor(existing, null).credential !== "brokered") return; throw new CustomServerRefusedError( - `${serverId} is a Composio app this deployment has enabled, and people may have connected their accounts to it. Remove the app first — which ends those accounts at Composio — and the name is then free.`, + `${serverId} is ${brokerProvider === "arcade" ? "an" : "a"} ${BROKERED_PROFILE[brokerProvider].vendor} app this deployment has enabled, and people may have connected their accounts to it. Remove the app first — which ends those accounts at ${BROKERED_PROFILE[brokerProvider].vendor} — and the name is then free.`, ); } @@ -7685,7 +7685,7 @@ export function createPluginStore(options: PluginStoreOptions) { */ if (probed.outcome === "unreachable") { throw new PluginRefusedError( - `${input.toolkit} could not be checked just now: ${probed.sentence} Nothing here changed — your connection is still recorded exactly as the last check left it — so pressing Re-check again when Composio is answering is the whole of the retry.`, + `${input.toolkit} could not be checked just now: ${probed.sentence} Nothing here changed — your connection is still recorded exactly as the last check left it — so pressing Re-check again when ${BROKERED_PROFILE[brokerProvider].vendor} is answering is the whole of the retry.`, null, ); } diff --git a/server/tests/routes-vendor-vocabulary.test.ts b/server/tests/routes-vendor-vocabulary.test.ts new file mode 100644 index 000000000..1ee0b095a --- /dev/null +++ b/server/tests/routes-vendor-vocabulary.test.ts @@ -0,0 +1,319 @@ +// server/tests/routes-vendor-vocabulary.test.ts +/** + * THE SENTENCES `routes.ts` HANDS A PERSON NAME THE BROKER THIS DEPLOYMENT ACTUALLY RUNS. + * + * Every refusal on the brokered surface used to spell the literal "Composio" — the app that could + * not be removed, the app that could not be enabled, the six ways a key app's connect can fail, the + * consent that could not be minted, and the confirm, re-check and disconnect tails. On an Arcade + * deployment each of them named a vendor this deployment has no key for and never talks to, and + * sent the reader to check a key that does not exist. + * + * ASSERTED ON BOTH PROVIDERS FROM ONE HARNESS, the way `routes-arcade-urls.test.ts` asserts its + * four reads. The Arcade half is what was red: the sentence must name Arcade and must not contain + * the word Composio anywhere. The Composio half is the evidence that nothing about that deployment + * moved — the same sentence, word for word, as before. + */ +import { describe, expect, test } from "bun:test"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { AccountModeSwitch } from "../src/plugins/account-mode"; +import type { + BrokerApp, + BrokerField, + ComposioBroker, +} from "../src/plugins/broker"; +import { createPluginRoutes } from "../src/plugins/routes"; +import type { SharedUseStore } from "../src/plugins/shared-use-store"; +import type { PluginStore } from "../src/plugins/store"; + +/** The row each provider holds for the same app, spelled the way that provider spells it. */ +const ROW: Record = { + composio: { url: "composio://gmail", toolkit: "gmail" }, + arcade: { url: "arcade://Gmail", toolkit: "Gmail" }, +}; + +/** The vendor name a deployment on each provider is entitled to read. */ +const VENDOR: Record = { + composio: "Composio", + arcade: "Arcade", +}; + +const SERVER_ID = "brokered-gmail"; +const TITLE = "Gmail"; + +/** A failure with no vendor sentence and no authored broker sentence, so the route's own generic is read. */ +const opaque = () => new Error("opaque"); + +function directoryApp(slug: string): BrokerApp { + return { + slug, + name: TITLE, + description: "Mail", + logo: null, + categories: [], + actionCount: 3, + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, + connection: { kind: "consent" }, + }; +} + +function field(name: string, required: boolean): BrokerField { + return { name, label: name, help: "", required, secret: true }; +} + +type Wiring = { + /** What the row says its authorization config was created as. */ + authScheme?: string | null; + /** Whether this deployment knows its own public address. */ + appUrl?: string | undefined; + /** What the broker publishes for a key app's form, or a throw. */ + fields?: () => Promise; + /** Every store or broker call the route makes on the way out, by name. */ + throwing?: Partial< + Record< + | "removeServer" + | "addBrokeredApp" + | "connectBrokeredWithFields" + | "authorize" + | "confirmBrokeredConnection" + | "recheckBrokeredConnection" + | "disconnectBrokered", + true + > + >; +}; + +function deployment(provider: BrokerProvider, wiring: Wiring = {}) { + const row = ROW[provider]; + const raise = (name: keyof NonNullable) => { + if (wiring.throwing?.[name]) throw opaque(); + }; + + const store = { + serverAddress: async (serverId: string) => + serverId === SERVER_ID + ? { + id: SERVER_ID, + title: TITLE, + url: row.url, + authScheme: + wiring.authScheme === undefined ? "OAUTH2" : wiring.authScheme, + } + : undefined, + serverUrls: async () => [row.url], + accountRefFor: async (_serverId: string, actorId: string) => ({ + ref: { + holder: "person" as const, + userId: actorId, + vendorUserId: actorId, + }, + title: TITLE, + mode: "personal" as const, + }), + brokeredConnection: async () => null, + removeServer: async () => { + raise("removeServer"); + }, + addBrokeredApp: async () => { + raise("addBrokeredApp"); + return {} as never; + }, + connectBrokeredWithFields: async () => { + raise("connectBrokeredWithFields"); + return {} as never; + }, + confirmBrokeredConnection: async () => { + raise("confirmBrokeredConnection"); + return { connected: true }; + }, + recheckBrokeredConnection: async () => { + raise("recheckBrokeredConnection"); + return { verified: true }; + }, + disconnectBrokered: async () => { + raise("disconnectBrokered"); + return { vendorRevocationRequested: true }; + }, + listSkills: async () => [], + } as unknown as PluginStore; + + const broker = { + grantIsLocal: true, + authProviderOf: async () => null, + authorizations: null, + listApps: async () => [directoryApp(row.toolkit)], + connectionFields: async () => + wiring.fields ? await wiring.fields() : ([] as BrokerField[]), + authorize: async () => { + raise("authorize"); + return { + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }; + }, + } as unknown as ComposioBroker; + + const modes = { + switchMode: async () => ({ ended: [], approved: [] }), + forgetRule: async () => {}, + } as unknown as AccountModeSwitch; + + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: "admin-1", + email: "admin@example.test", + role: "admin", + } as never); + await next(); + }; + + const app = new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + async () => true, + { + publicUrl: "https://openbot.example", + appUrl: "appUrl" in wiring ? wiring.appUrl : "https://app.example", + encryptionKey: `${"A".repeat(43)}=`, + personHasAccess: async () => true, + }, + { broker, provider }, + { modes, use: {} as unknown as SharedUseStore }, + ), + ); + + const post = (path: string, body: unknown = {}) => + app.request(`http://openbot.test${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + return { + toolkit: row.toolkit, + remove: () => + app.request(`http://openbot.test/api/plugins/servers/${SERVER_ID}`, { + method: "DELETE", + }), + enable: () => post("/api/plugins/composio/apps", { slug: row.toolkit }), + connect: (body?: unknown) => + post(`/api/plugins/servers/${SERVER_ID}/connect`, body ?? {}), + confirm: () => post(`/api/plugins/servers/${SERVER_ID}/connection/confirm`), + recheck: () => post(`/api/plugins/servers/${SERVER_ID}/connection/recheck`), + disconnect: () => + app.request( + `http://openbot.test/api/plugins/servers/${SERVER_ID}/connection`, + { method: "DELETE" }, + ), + }; +} + +/** Every sentence this file pins, as the request that produces it. */ +const CASES: Record Promise> = { + "removing an app that would not finish": (provider) => + deployment(provider, { throwing: { removeServer: true } }).remove(), + "enabling an app the broker would not enable": (provider) => + deployment(provider, { throwing: { addBrokeredApp: true } }).enable(), + "a key app whose form the broker would not publish": (provider) => + deployment(provider, { + authScheme: "API_KEY", + fields: () => Promise.reject(opaque()), + }).connect(), + "a key app that publishes no boxes at all": (provider) => + deployment(provider, { + authScheme: "API_KEY", + fields: async () => [], + }).connect({ values: {} }), + "a key app that publishes only optional boxes": (provider) => + deployment(provider, { + authScheme: "API_KEY", + fields: async () => [field("workspace", false)], + }).connect({ values: {} }), + "a submission that is not the published form": (provider) => + deployment(provider, { + authScheme: "API_KEY", + fields: async () => [field("api_key", true)], + }).connect({ values: { nonsense: "x" } }), + "a submission missing a required box": (provider) => + deployment(provider, { + authScheme: "API_KEY", + fields: async () => [field("api_key", true)], + }).connect({ values: { api_key: " " } }), + "a key the store could not connect with": (provider) => + deployment(provider, { + authScheme: "API_KEY", + fields: async () => [field("api_key", true)], + throwing: { connectBrokeredWithFields: true }, + }).connect({ values: { api_key: "secret" } }), + "a row whose scheme this deployment cannot read": (provider) => + deployment(provider, { authScheme: "SOMETHING_ELSE" }).connect(), + "a consent on a deployment with no app URL": (provider) => + deployment(provider, { appUrl: undefined }).connect(), + "a consent the broker would not begin": (provider) => + deployment(provider, { throwing: { authorize: true } }).connect(), + "a confirm the broker would not answer": (provider) => + deployment(provider, { + throwing: { confirmBrokeredConnection: true }, + }).confirm(), + "a re-check the broker would not answer": (provider) => + deployment(provider, { + throwing: { recheckBrokeredConnection: true }, + }).recheck(), + "a disconnect the broker would not finish": (provider) => + deployment(provider, { + throwing: { disconnectBrokered: true }, + }).disconnect(), +}; + +async function sentenceOf( + provider: BrokerProvider, + name: keyof typeof CASES, +): Promise { + const response = await CASES[name](provider); + const body = (await response.json()) as { error?: unknown }; + expect(typeof body.error).toBe("string"); + return body.error as string; +} + +describe("an Arcade deployment's refusals name Arcade and never Composio", () => { + for (const name of Object.keys(CASES)) { + test(name, async () => { + const sentence = await sentenceOf("arcade", name); + expect(sentence).not.toContain("Composio"); + expect(sentence).toContain(VENDOR.arcade); + }); + } +}); + +describe("and a Composio deployment reads exactly what it read before", () => { + for (const name of Object.keys(CASES)) { + test(name, async () => { + const sentence = await sentenceOf("composio", name); + expect(sentence).not.toContain("Arcade"); + expect(sentence).toContain(VENDOR.composio); + }); + } +}); + +/** + * And the two sentences differ in NOTHING but the vendor's name — which is what says the fix was a + * substitution rather than a rewrite. Written as the Arcade sentence with "Arcade" put back to + * "Composio", compared against the Composio deployment's own answer. + */ +describe("the two deployments' sentences differ only in the vendor's name", () => { + for (const name of Object.keys(CASES)) { + test(name, async () => { + const arcade = await sentenceOf("arcade", name); + const composio = await sentenceOf("composio", name); + expect(arcade.replaceAll("Arcade", "Composio")).toBe(composio); + }); + } +}); diff --git a/server/tests/store-vendor-vocabulary.test.ts b/server/tests/store-vendor-vocabulary.test.ts new file mode 100644 index 000000000..d6a95b9dc --- /dev/null +++ b/server/tests/store-vendor-vocabulary.test.ts @@ -0,0 +1,259 @@ +// server/tests/store-vendor-vocabulary.test.ts +/** + * THE SENTENCES `store.ts` HANDS A PERSON NAME THE BROKER THIS DEPLOYMENT ACTUALLY RUNS. + * + * Two of the store's refusals spelled the literal "Composio" into prose a reader acts on: the name + * collision an administrator meets when they add a server by URL over a brokered row, and the + * retry a person is told to make when a re-check could not be made. Both reach a browser — the + * first as a 400 off `CustomServerRefusedError`, the second as a 400 off `PluginRefusedError` — + * and on an Arcade deployment both named a vendor this deployment holds no key for. + * + * ONE DATABASE, TWO STORES, because the only thing under test is which vocabulary a store built on + * one provider reads. Rows are inserted rather than enabled through `addBrokeredApp`: what these + * refusals read off a row is its provenance, its url and its scheme, and enabling for real would + * put a vendor listing in front of an assertion that has nothing to do with one. + */ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { + agents, + brokeredConnections, + mcpServers, + mcpTools, +} from "../src/db/schema"; +import type { BrokerProvider } from "../src/plugins/access"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +afterAll(async () => { + await database.$client.close(); +}); + +const suite = randomUUID().slice(0, 8); + +/** Nothing here reaches a vendor: every refusal under test is decided off the row. */ +const broker = { + grantIsLocal: false, + authorizations: null, + authProviderOf: async () => null, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), + isConnected: async () => true, + revoke: async () => true, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => null, +} as unknown as ConnectedAppBroker; + +function storeOn(provider: BrokerProvider) { + return createPluginStore({ + brokerProvider: provider, + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, + }); +} + +const STORE: Record> = { + composio: storeOn("composio"), + arcade: storeOn("arcade"), +}; + +const VENDOR: Record = { + composio: "Composio", + arcade: "Arcade", +}; + +const seededServers: string[] = []; +const seededApps: string[] = []; + +/** A brokered app as its provider spells it, inserted whole. */ +async function brokeredApp(provider: BrokerProvider, toolkit: string) { + // NOT the `${provider}-` id `addBrokeredApp` composes: an id in a broker's reserved namespace is + // refused by its own guard before `requireNotBrokered` is ever asked, and that is a different + // refusal with a different sentence. + const id = `held-${toolkit.toLowerCase()}`; + seededServers.push(id); + await database.insert(mcpServers).values({ + id, + title: toolkit, + vendor: VENDOR[provider], + url: `${provider}://${toolkit}`, + provenance: provider, + authScheme: "API_KEY", + }); + return id; +} + +afterEach(async () => { + if (seededServers.length) { + await database + .delete(mcpTools) + .where(inArray(mcpTools.serverId, seededServers)); + await database + .delete(mcpServers) + .where(inArray(mcpServers.id, seededServers)); + } + if (seededApps.length) + await database + .delete(brokeredConnections) + .where(inArray(brokeredConnections.app, seededApps)); + seededServers.length = 0; + seededApps.length = 0; +}); + +/** + * ADDING A SERVER BY URL OVER A BROKERED ROW. The refusal names the vendor whose accounts are + * standing behind that row, which is the vendor the administrator has to go and remove the app + * from. + */ +describe("the brokered-name collision names the active vendor", () => { + for (const provider of ["arcade", "composio"] as BrokerProvider[]) { + test(`on ${provider}`, async () => { + const toolkit = `Gmail${suite}`; + const id = await brokeredApp(provider, toolkit); + + const refused = await STORE[provider] + .addCustomServer({ + id, + title: "Collector", + url: "https://collector.example/mcp", + by: "admin@example.test", + }) + .then( + () => null, + (error: Error) => error.message, + ); + + expect(refused).not.toBeNull(); + expect(refused).toContain(VENDOR[provider]); + expect(refused).not.toContain( + VENDOR[provider === "arcade" ? "composio" : "arcade"], + ); + }); + } +}); + +/** + * A RE-CHECK THAT COULD NOT BE MADE. The probe itself has no second implementation yet and says so + * in its own words; what this pins is the RETRY the person is told to make, which is about the + * vendor this deployment is waiting on and not about the one the probe was written for. + */ +describe("the re-check retry names the active vendor", () => { + test("on arcade", async () => { + const toolkit = `Slack${suite}`; + const id = await brokeredApp("arcade", toolkit); + seededApps.push(toolkit); + await database.insert(mcpTools).values({ + serverId: id, + name: `${toolkit}.WhoAmI`, + description: "Says who the key belongs to.", + effect: "read", + version: "1.0", + }); + await database.insert(brokeredConnections).values({ + provider: "arcade", + app: toolkit, + holder: "person", + userId: "person-1", + vendorUserId: "person-1", + }); + + const refused = await STORE.arcade + .recheckBrokeredConnection({ + toolkit, + account: { + holder: "person", + userId: "person-1", + vendorUserId: "person-1", + }, + by: "person-1", + }) + .then( + () => null, + (error: Error) => error.message, + ); + + expect(refused).toContain( + "pressing Re-check again when Arcade is answering", + ); + expect(refused).not.toContain("when Composio is answering"); + }); +}); + +/** + * A BROKERED ROW WHOSE URL NAMES NO APP. Nothing in the product writes one — a hand edit or an old + * backup does — and the invariant it raises reaches an operator as a 409 off `isDeploymentFault`. + * It named the wrong vendor on an Arcade deployment, which is the same defect wearing an + * invariant's clothes. + */ +describe("the brokered-credential invariant names the active vendor", () => { + test("on arcade", async () => { + const id = `held-orphan-${suite}`; + const botId = `bot-${suite}`; + const action = "Orphan.Read"; + seededServers.push(id); + await database.insert(agents).values({ + id: botId, + name: "Helper", + type: "built_in", + configuration: {}, + }); + await database.insert(mcpServers).values({ + id, + title: "Orphan", + vendor: "Arcade", + // Brokered provenance, and a url that names no app at all. + url: "https://orphan.example/mcp", + provenance: "arcade", + authScheme: "API_KEY", + accountMode: "personal", + }); + await database.insert(mcpTools).values({ + serverId: id, + name: action, + description: "Reads something.", + effect: "read", + version: "1.0", + }); + await STORE.arcade.grant( + "mcp", + `${id}/${action}`, + botId, + "admin@example.test", + ); + + const raised = await STORE.arcade + .callTool({ + ref: `${id}/${action}`, + args: {}, + botId, + actorId: "person-1", + }) + .then( + () => null, + (error: Error) => error.message, + ); + + expect(raised).toContain("Arcade"); + expect(raised).not.toContain("Composio"); + + await database.delete(agents).where(inArray(agents.id, [botId])); + }); +}); From 17f7dc9d148a85a4684fb7b659e648298c7b2985 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 22:55:52 +0200 Subject: [PATCH 134/232] fix(plugins): never read an undecided provider as Composio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `activeProvider` answers null for three different reasons and `?? "composio"` flattened all three into "Composio is active". The dangerous one is both keys set with OPENBOT_BROKER_PROVIDER unset — a state docs/plugins/arcade.md calls legal and the one a migrating deployment passes through. In it the plugin store and the shared-use store were keyed to provider='composio', so every Arcade row was filtered out of existence: live Arcade connections read as absent and the accounts behind them could not be revoked. The plugins routes made the same default a second time (`composio?.provider ?? "composio"`), so with no broker wired every `arcade://` row was parsed with Composio's parser — the account-mode switch refused it as "not reached through a broker", the directory called enabled apps not enabled, connect fell past its brokered branch, and the operator was told to set COMPOSIO_API_KEY. The three causes are now separate answers: - no key set, or both keys with nothing named: legal, no broker is wired, and the slice the stores read is named by the brokered_connections rows the deployment actually holds. Asking the rows cannot be wrong about them. - rows at BOTH vendors while nothing is active: refused at startup, which is assertActivatable's refusal seen from the other side. - a named provider whose key is unset: still refused at startup, unchanged. With no rows of either vendor the name labels an empty set and no row can appear, because writing one needs a live broker; the comment on storedProvider says so. The ambiguous case also warns once at startup that nothing is active. Symbols: - broker.ts, new: `ProviderDecision`, `providerDecision`. Called from index.ts, plugins/provider-wiring.ts, tests/provider-undecided.test.ts. - broker.ts: `activeProvider` kept, same signature, now implemented over `providerDecision`; no longer called in src (its callers now need the cause), still covered by tests/provider-selection.test.ts. - provider-wiring.ts, new: `storedProvider`. Called from index.ts and tests/provider-undecided.test.ts. - store.ts: `createPluginStore` now returns its `brokerProvider`, so routes.ts reads the one name the deployment decided instead of deciding a second time. Read in plugins/routes.ts; every stand-in PluginStore handed to createPluginRoutes now carries it (11 test files), and the routes refuse at construction rather than dispatching on undefined. Red-green: tests/provider-undecided.test.ts. Against the unfixed routes the account-mode case answered 400 and the page answered "composio"; it now answers 200 and "arcade". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- docs/plugins/arcade.md | 10 +- server/src/index.ts | 46 ++++- server/src/plugins/broker.ts | 71 ++++++-- server/src/plugins/provider-wiring.ts | 75 +++++++- server/src/plugins/routes.ts | 39 +++-- server/src/plugins/store.ts | 12 ++ server/tests/agent-callback-token.test.ts | 2 + server/tests/bot-access.test.ts | 1 + ...component-grants-skills-validation.test.ts | 1 + .../tests/directory-vendor-sentences.test.ts | 5 +- .../tests/plugin-call-args-validation.test.ts | 1 + server/tests/plugin-connect-route.test.ts | 2 + server/tests/plugin-grants-validation.test.ts | 2 + server/tests/plugin-oauth-callback.test.ts | 1 + server/tests/plugin-routes.test.ts | 4 +- .../tests/plugin-servers-validation.test.ts | 2 + server/tests/plugins-page-provider.test.ts | 53 ++++-- server/tests/provider-undecided.test.ts | 163 ++++++++++++++++++ server/tests/routes-arcade-urls.test.ts | 1 + 19 files changed, 430 insertions(+), 61 deletions(-) create mode 100644 server/tests/provider-undecided.test.ts diff --git a/docs/plugins/arcade.md b/docs/plugins/arcade.md index 69ddc7968..5d6e03142 100644 --- a/docs/plugins/arcade.md +++ b/docs/plugins/arcade.md @@ -39,10 +39,18 @@ one, so no existing deployment has to set anything.** The variable matters only set. Setting both is legal and expected, because ending an account at the old vendor needs that vendor's key. With both set and the variable unset, neither is active. -Two refusals protect this, and both are deliberate: +**Connections already made are still read as their own provider's while nothing is active.** A +deployment where configuration names no provider — both keys set with the variable unset, or a key +withdrawn afterwards — takes the name from the `brokered_connections` rows it actually holds, so an +Arcade deployment part-way through this does not have its Arcade connections read as Composio ones, +shown as absent, and left unrevokable. No broker is live in that state, so nothing can be connected +or revoked until a provider is named, and the server says so once at startup. + +Three refusals protect this, and all three are deliberate: - **A named provider whose key is not set stops the server at startup.** An operator sees: _OPENBOT_BROKER_PROVIDER=arcade names a provider whose ARCADE_API_KEY is not set. Set ARCADE_API_KEY, or change or unset OPENBOT_BROKER_PROVIDER._ It does not fall back to a deployment with no broker, because that would send somebody looking for a broken vendor instead of at their own environment. - **Activating a provider is refused while the other one still holds connections.** It is keyed on connection rows, not on keys. The message names the provider being activated, how many connections still belong to the other, and tells the operator to keep both keys set and end those connections through the other provider's sunset flow first. Switching by editing the environment would leave accounts nobody can revoke. +- **Holding connections at BOTH providers while nothing is active stops the server at startup.** It is the refusal above seen from the other side: with no provider named there is nothing to read one vendor's rows as, and reading them as the other's is the failure this whole section exists to prevent. The message names both vendors and asks the operator to set `OPENBOT_BROKER_PROVIDER` and to end the other provider's connections through its own sunset flow. ## Setting up the Arcade project diff --git a/server/src/index.ts b/server/src/index.ts index 2d42db28c..058d21c7c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -212,7 +212,7 @@ import { createPasswordStore } from "./passwords/store"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { createAccountModeSwitch } from "./plugins/account-mode"; -import { activeProvider, assertActivatable } from "./plugins/broker"; +import { assertActivatable, providerDecision } from "./plugins/broker"; import { BROKERED_PROFILE } from "./plugins/access"; import { useRoutineTools } from "./plugins/builtin-routines"; import { useArcadeClient } from "./plugins/arcade"; @@ -225,7 +225,10 @@ import { createSharedUseGate, createSharedUseStore, } from "./plugins/shared-use-store"; -import { selectProviderWiring } from "./plugins/provider-wiring"; +import { + selectProviderWiring, + storedProvider, +} from "./plugins/provider-wiring"; import { createPluginStore } from "./plugins/store"; import { grantedSkills, @@ -874,14 +877,39 @@ const signInService = computerGateway * or refile against it when a Bot's exposure changes. */ /* - * `activeProvider` answers `null` only when this deployment has no broker at all: no key is set, - * so no broker reaches the store, no brokered row exists, and the value merely names which empty - * slice an unreachable query would read. That is why the one `??` in the system is HERE and only - * here — the single place where "which provider" has no answer, visible at the deployment boundary - * instead of asserted forty times over inside a constructor. The shared-use store and the plugin - * store below are both given this one value, so the gate and the approvals it reads agree. + * WHICH SLICE of `brokered_connections` this deployment reads, decided ONCE, at the one boundary + * that can see both the configuration and the rows. + * + * THERE IS NO `?? "composio"` HERE ANY MORE, AND THAT WAS A BUG RATHER THAN A SHORTHAND. + * Configuration names no provider for three different reasons, and the one that matters is "both + * keys set, OPENBOT_BROKER_PROVIDER unset" — legal, documented, and exactly what a deployment + * migrating off Composio sits in while it ends its old accounts. Defaulting it to `composio` gave + * the plugin store and the shared-use store a filter that excludes every Arcade row this deployment + * holds, so live Arcade connections read as absent and the accounts behind them could not be + * revoked. `storedProvider` takes the three causes apart and, where configuration is silent, lets + * the rows name themselves. The shared-use store and the plugin store are both given this one + * value, so the gate and the approvals it reads agree. */ -const brokerProvider = activeProvider(config) ?? "composio"; +const providerConfiguration = providerDecision(config); +const brokerProvider = storedProvider( + providerConfiguration, + ( + await database + .selectDistinct({ provider: brokeredConnections.provider }) + .from(brokeredConnections) + ).map((row) => row.provider), +); +if (providerConfiguration.kind === "ambiguous") { + /* + * Said out loud, once, at startup: this deployment has two keys and no answer, so nothing is + * active and nothing can be connected or revoked until one is named. The rows it already holds + * are read as their own provider's (above), which is why this is a warning and not a refusal — + * the screens stay honest, the vendor calls stay impossible. + */ + console.warn( + `${providerConfiguration.configured.map((provider) => BROKERED_PROFILE[provider].envVar).join(" and ")} are both set and OPENBOT_BROKER_PROVIDER names neither, so no broker is active: connected apps can be neither connected nor revoked until it is set. Existing connections are read as ${BROKERED_PROFILE[brokerProvider].vendor}'s.`, + ); +} const sharedUseStore = createSharedUseStore(database, brokerProvider); const pluginStore = createPluginStore({ diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 2107882c4..3892f43e8 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -993,26 +993,73 @@ export function brokerSentence(error: unknown): string | null { return error instanceof BrokerRefusalError ? error.message : null; } +/** What configuration says about which broker is active, INCLUDING why it says nothing. */ +export type ProviderDecision = + | { kind: "active"; provider: BrokerProvider } + /** No key is set: this deployment has no broker and never had one through configuration. */ + | { kind: "unconfigured" } + /** + * Both keys are set and `OPENBOT_BROKER_PROVIDER` names neither, so configuration proposes two + * providers and chooses between them nowhere. A legal state — `docs/plugins/arcade.md` says so, + * and a migrating deployment passes through it — and the one an answer must never be invented + * for. + */ + | { kind: "ambiguous"; configured: BrokerProvider[] } + /** `OPENBOT_BROKER_PROVIDER` names a provider whose key is unset: an operator's typo. */ + | { kind: "misnamed"; named: BrokerProvider }; + /** - * Which broker this deployment runs, from configuration alone (no database access). + * Which broker this deployment runs, from configuration alone (no database access), AND WHY WHEN + * NOTHING DOES. * - * One key set selects that provider. Both set is legal and expected, since ending an account at - * the old vendor needs its key, so `brokerProvider` decides and unset means `null`. A - * `brokerProvider` naming a provider whose key is unset is refused (`null`), never selected. + * THE THREE CAUSES ARE NOT ONE CAUSE, which is why this answers a decision rather than + * `BrokerProvider | null`. "No key set" is a deployment with no broker and no brokered row of its + * own; "both keys, nothing named" is a migration in progress, with live rows at one vendor or the + * other and nothing in configuration saying which; "named but unkeyed" is a typo that must stop the + * server. A single `null` made all three the same value, and every caller that then wrote + * `?? "composio"` turned the second one — the only one that can hold Arcade rows — into "Composio + * is active", which filtered those rows out of existence and left the accounts behind them + * unrevokable. + * + * One key set selects that provider. Both set is legal and expected, since ending an account at the + * old vendor needs its key, so `brokerProvider` decides between them. */ -export function activeProvider(c: { +export function providerDecision(c: { composioApiKey?: string; arcadeApiKey?: string; brokerProvider?: BrokerProvider; -}): BrokerProvider | null { - const composio = c.composioApiKey !== undefined; - const arcade = c.arcadeApiKey !== undefined; +}): ProviderDecision { const configured: BrokerProvider[] = []; - if (composio) configured.push("composio"); - if (arcade) configured.push("arcade"); + if (c.composioApiKey !== undefined) configured.push("composio"); + if (c.arcadeApiKey !== undefined) configured.push("arcade"); const named = c.brokerProvider; - if (named !== undefined) return configured.includes(named) ? named : null; - return configured.length === 1 ? configured[0] : null; + if (named !== undefined) { + return configured.includes(named) + ? { kind: "active", provider: named } + : { kind: "misnamed", named }; + } + if (configured.length === 1) + return { kind: "active", provider: configured[0] as BrokerProvider }; + return configured.length === 0 + ? { kind: "unconfigured" } + : { kind: "ambiguous", configured }; +} + +/** + * The active provider, or `null` for every deployment where configuration names none. + * + * KEPT FOR THE CALLERS THAT GENUINELY ASK A YES/NO QUESTION — "is there a broker to wire" — and + * nothing more. A caller that has to NAME a provider when this answers null is asking the question + * {@link providerDecision} answers, and must take the three causes apart instead of defaulting one + * of them to a vendor. + */ +export function activeProvider(c: { + composioApiKey?: string; + arcadeApiKey?: string; + brokerProvider?: BrokerProvider; +}): BrokerProvider | null { + const decision = providerDecision(c); + return decision.kind === "active" ? decision.provider : null; } /** diff --git a/server/src/plugins/provider-wiring.ts b/server/src/plugins/provider-wiring.ts index 971d9bd4f..48eb89908 100644 --- a/server/src/plugins/provider-wiring.ts +++ b/server/src/plugins/provider-wiring.ts @@ -1,7 +1,11 @@ import type { ArcadeActions } from "./arcade"; import type { ComposioActions } from "./composio"; -import { activeProvider, type ConnectedAppBroker } from "./broker"; -import type { BrokerProvider } from "./access"; +import { + providerDecision, + type ConnectedAppBroker, + type ProviderDecision, +} from "./broker"; +import { BROKERED_PROFILE, type BrokerProvider } from "./access"; /** * The entrypoint's provider decision, with the vendor constructors passed in so this file imports @@ -24,7 +28,7 @@ export type ProviderWiring = { /** * Chooses ONE provider from configuration and builds only that provider's client. * - * `activeProvider` answers `null` in three situations: no keys, both keys with nothing named, and + * Configuration names no provider in three situations: no keys, both keys with nothing named, and * a named provider whose key is unset. The first two are legal states. The third is an operator * typo, and degrading it to a deployment with no broker would send them looking for a broken * vendor instead of at their own environment, so it is refused here, naming the variables and @@ -41,14 +45,14 @@ export function selectProviderWiring( arcade: ClientFactory; }, ): ProviderWiring { - const provider = activeProvider(c); - if (provider === null && c.brokerProvider !== undefined) { - const keyName = - c.brokerProvider === "arcade" ? "ARCADE_API_KEY" : "COMPOSIO_API_KEY"; + const decision = providerDecision(c); + if (decision.kind === "misnamed") { + const keyName = BROKERED_PROFILE[decision.named].envVar; throw new Error( - `OPENBOT_BROKER_PROVIDER=${c.brokerProvider} names a provider whose ${keyName} is not set. Set ${keyName}, or change or unset OPENBOT_BROKER_PROVIDER.`, + `OPENBOT_BROKER_PROVIDER=${decision.named} names a provider whose ${keyName} is not set. Set ${keyName}, or change or unset OPENBOT_BROKER_PROVIDER.`, ); } + const provider = decision.kind === "active" ? decision.provider : null; if (provider === "arcade" && c.arcadeApiKey !== undefined) { const client = create.arcade(c.arcadeApiKey); return { @@ -71,3 +75,58 @@ export function selectProviderWiring( install: { composio: null, arcade: null }, }; } + +/** + * WHICH SLICE of `brokered_connections` this deployment's stores read and write, which is a + * different question from which broker is active, and has an answer where that one has none. + * + * CONFIGURATION FIRST, THEN THE ROWS, AND NEVER A VENDOR PICKED OUT OF THE AIR. An active provider + * is the answer. Where configuration names none — no key set, or both keys set with + * `OPENBOT_BROKER_PROVIDER` unset, which is a legal state a migrating deployment passes through — + * the rows this deployment actually holds name it instead. That is the whole fix: `?? "composio"` + * answered "Composio" for a deployment whose every connection is an Arcade one, so those rows were + * filtered out of every read, parsed with the wrong parser, and the live accounts behind them could + * be neither seen nor revoked. Asking the rows cannot be wrong about them, because they are what is + * being named. + * + * ROWS AT BOTH VENDORS WITH NOTHING ACTIVE IS REFUSED, which is {@link assertActivatable}'s refusal + * from the other side: that one stops a deployment activating one provider while the other holds + * rows, and this one stops a deployment reading one vendor's rows as the other's while NOTHING is + * active. Both say the same thing to an operator — name a provider, and end the other's connections + * through its own sunset flow. + * + * AND NO ROWS AT ALL NAMES AN EMPTY SLICE. A `BrokerProvider` has to be spelled, and with no row of + * either vendor there is no row to misfile: every `eq(provider, …)` selects nothing whichever name + * is used, and no row can appear while this holds, because writing one needs a live broker and + * there is none. The name is a label on an empty set, not a claim that Composio is active — what + * answers that is `providerWiring.broker`, which is `null` here. + */ +export function storedProvider( + decision: ProviderDecision, + rowOwners: BrokerProvider[], +): BrokerProvider { + if (decision.kind === "active") return decision.provider; + if (decision.kind === "misnamed") { + // Unreachable: `selectProviderWiring` stops the server on this before any store is built. + throw new Error( + `OPENBOT_BROKER_PROVIDER=${decision.named} names a provider whose ${BROKERED_PROFILE[decision.named].envVar} is not set.`, + ); + } + const owners = [...new Set(rowOwners)]; + if (owners.length > 1) { + const counted = owners + .map((owner) => BROKERED_PROFILE[owner].vendor) + .join(" and "); + throw new Error( + `This deployment holds brokered connections at ${counted}, and nothing names which provider is active: ${ + decision.kind === "ambiguous" + ? `${decision.configured.map((p) => BROKERED_PROFILE[p].envVar).join(" and ")} are both set and OPENBOT_BROKER_PROVIDER is unset` + : "no broker API key is set" + }. Set OPENBOT_BROKER_PROVIDER to the provider those connections are to be read as, and end the other provider's connections through its own sunset flow.`, + ); + } + const owner = owners[0]; + if (owner !== undefined) return owner; + // No rows of either vendor. See the last paragraph above: this labels an empty set. + return "composio"; +} diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 95048893f..f62dfc157 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -330,20 +330,33 @@ export function createPluginRoutes( * would be the comparison `store.ts` argues against, made against a capability that two providers * are free to share. * - * THE `??` IS NOT A SECOND FALLBACK: IT IS THE PAGE PAYLOAD'S, MOVED UP ONE LINE SO THERE IS ONE. - * The payload below already answered `composio` for a deployment with no broker, and the four - * brokered reads below have always parsed with Composio's own parser whether or not a key was - * set. Spelling it in both places is how two readings of "which provider" come apart; spelled - * here, the name the page shows and the parser the routes use cannot disagree. - * - * AND NO BROKER IS NOT THE CASE THIS DECIDES. A deployment with no key reaches no broker, so no - * row of its own can be brokered — but rows outlive a key being unset, and the Personal ⇄ Shared - * switch is wired from the shared bag rather than from the broker, so it answers for such a row - * today. Reading `null` for it instead would turn that 200 into a 400 in the name of a provider - * question nobody asked. The deployment boundary is where "which provider" genuinely has no - * answer, and `server/src/index.ts` is where the system says so. + * TAKEN FROM THE STORE, BECAUSE THE STORE'S ROWS ARE WHAT IS BEING NAMED. It used to be read off + * the broker bag with `?? "composio"` behind it, and that `??` was a second answer to a question + * `server/src/index.ts` had already answered. The two came apart exactly where it hurt: a + * deployment with both API keys set and `OPENBOT_BROKER_PROVIDER` unset — legal, and what a + * migration sits in — is wired with NO broker, so the bag is absent, so every `arcade://` row of + * that deployment was parsed with Composio's parser. The account-mode switch refused those rows + * as "not reached through a broker", the directory called enabled apps not enabled, connect fell + * past its brokered branch in silence, and the operator was told to set COMPOSIO_API_KEY. The + * store was built with the one name the deployment decided; reading it here means the parser + * these routes use, the filter the store queries with and the name the page shows are one value. + * + * NO BROKER IS STILL NOT THE CASE THIS DECIDES. Rows outlive a key being unset, and the + * Personal ⇄ Shared switch is wired from the shared bag rather than from the broker, so it + * answers for such a row today — now with that row's own parser rather than Composio's. */ - const activeProvider: BrokerProvider = composio?.provider ?? "composio"; + const activeProvider: BrokerProvider = store.brokerProvider; + /* + * Said at construction, not at the first request. A store without this field is a stand-in rather + * than a store, and one that got this far would have every brokered read dispatch on `undefined` + * — four routes answering wrongly, none of them raising. Typed as present, so this catches only a + * cast; it refuses rather than filling in a vendor, which is the whole of this file's bug. + */ + if (!activeProvider) { + throw new Error( + "createPluginRoutes was given a plugin store with no brokerProvider. The store names the provider its rows belong to; the routes never guess one.", + ); + } /** * Which app a brokered row is, read by the parser belonging to the broker this deployment runs. diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index d075e1415..7114538a1 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3023,6 +3023,18 @@ export function createPluginStore(options: PluginStoreOptions) { } return { + /** + * The one provider this store's rows belong to, readable by the layer above it. + * + * EXPOSED SO THE ROUTES DO NOT DECIDE IT A SECOND TIME. `routes.ts` used to take the name off + * the broker it was handed and write `?? "composio"` when there was none — which is a second + * answer to the question {@link PluginStoreOptions.brokerProvider} argues must have exactly one. + * The two came apart precisely where it hurt: a deployment with both keys set and + * `OPENBOT_BROKER_PROVIDER` unset is wired with no broker at all, so the routes read every + * `arcade://` row of this store with Composio's parser. Read from here, the parser the routes + * use and the filter this store queries with cannot disagree. + */ + brokerProvider, accountRefFor, /** * Add a server from the catalogue. diff --git a/server/tests/agent-callback-token.test.ts b/server/tests/agent-callback-token.test.ts index a73a6ad44..f80e640d5 100644 --- a/server/tests/agent-callback-token.test.ts +++ b/server/tests/agent-callback-token.test.ts @@ -420,6 +420,7 @@ describe("the tool-call route a callback token guards", () => { undefined, undefined, { + brokerProvider: "composio" as const, callTool: async () => { throw thrown; }, @@ -587,6 +588,7 @@ describe("the tool-call route a callback token guards", () => { */ function storeAnswering(result: { text: string; isError: boolean }) { return { + brokerProvider: "composio" as const, callTool: async () => ({ ...result, truncated: false }), listForAgent: async () => ({ tools: [ diff --git a/server/tests/bot-access.test.ts b/server/tests/bot-access.test.ts index af8ef8113..96304a59c 100644 --- a/server/tests/bot-access.test.ts +++ b/server/tests/bot-access.test.ts @@ -359,6 +359,7 @@ describe("calling a tool as a Bot", () => { function app(actorId: string) { const called: string[] = []; const store = { + brokerProvider: "composio" as const, callTool: async (input: { ref: string; botId: string }) => { called.push(`${input.ref}@${input.botId}`); return { ok: true }; diff --git a/server/tests/component-grants-skills-validation.test.ts b/server/tests/component-grants-skills-validation.test.ts index 5728a6d5c..c8c89050a 100644 --- a/server/tests/component-grants-skills-validation.test.ts +++ b/server/tests/component-grants-skills-validation.test.ts @@ -23,6 +23,7 @@ const canUseBot: BotAccessCheck = async () => true; function pluginAppWith(calls: { skills: unknown[] }) { const store = { + brokerProvider: "composio" as const, installSkill: async (input: unknown) => { calls.skills.push(input); return { ok: true }; diff --git a/server/tests/directory-vendor-sentences.test.ts b/server/tests/directory-vendor-sentences.test.ts index 8a2293c53..c5fac8d21 100644 --- a/server/tests/directory-vendor-sentences.test.ts +++ b/server/tests/directory-vendor-sentences.test.ts @@ -42,7 +42,10 @@ function deployment(provider: BrokerProvider, listApps: () => Promise) { const app = new Hono().route( "/api/plugins", createPluginRoutes( - { serverUrls: async () => [] } as unknown as PluginStore, + { + brokerProvider: provider, + serverUrls: async () => [], + } as unknown as PluginStore, signedIn, async () => true, { diff --git a/server/tests/plugin-call-args-validation.test.ts b/server/tests/plugin-call-args-validation.test.ts index 607d59257..5afb632d2 100644 --- a/server/tests/plugin-call-args-validation.test.ts +++ b/server/tests/plugin-call-args-validation.test.ts @@ -27,6 +27,7 @@ const canUseBot: BotAccessCheck = async () => true; describe("POST /api/plugins/call args", () => { function appWith(calls: unknown[]) { const store = { + brokerProvider: "composio" as const, callTool: async (input: unknown) => { calls.push(input); return { ok: true }; diff --git a/server/tests/plugin-connect-route.test.ts b/server/tests/plugin-connect-route.test.ts index aa995c3e5..11518a312 100644 --- a/server/tests/plugin-connect-route.test.ts +++ b/server/tests/plugin-connect-route.test.ts @@ -41,6 +41,8 @@ function app(store: { }) { const routes = createPluginRoutes( { + // The provider these rows belong to, which the routes read rather than guess. + brokerProvider: "composio" as const, /* * The read that decides whether this is a brokered row, which the handler makes before * anything about the consent flow. None of these vendors is one: a brokered app is reached diff --git a/server/tests/plugin-grants-validation.test.ts b/server/tests/plugin-grants-validation.test.ts index 30580cb2c..43ee18406 100644 --- a/server/tests/plugin-grants-validation.test.ts +++ b/server/tests/plugin-grants-validation.test.ts @@ -21,6 +21,7 @@ function appWith( bots: string[] = ["bot-1"], ) { const store = { + brokerProvider: "composio" as const, serverExists: async (serverId: string) => { calls.serverLookups?.push(serverId); return servers.includes(serverId); @@ -358,6 +359,7 @@ describe("POST /api/plugins/call", () => { describe("POST /api/plugins/skills", () => { function skillsApp(calls: { installs: unknown[] }) { const store = { + brokerProvider: "composio" as const, grant: async () => ({ ok: true }), callTool: async () => ({ ok: true }), skillOwner: async () => null, diff --git a/server/tests/plugin-oauth-callback.test.ts b/server/tests/plugin-oauth-callback.test.ts index 641eaf0ff..8f3da276f 100644 --- a/server/tests/plugin-oauth-callback.test.ts +++ b/server/tests/plugin-oauth-callback.test.ts @@ -63,6 +63,7 @@ function app(input: { oauthClientFor?: () => Promise<{ clientId: string; clientSecret: string }>; }) { const store = { + brokerProvider: "composio" as const, // The brokered read the connect handler makes before anything about the consent flow. This // deployment has no brokered rows, so it answers nothing and the flow below is untouched. serverAddress: async () => undefined, diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index 034c3afd9..7b3af389d 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -48,7 +48,9 @@ const PERSON_UNCONFIGURED = * the silence: see {@link UP_TO_PLUGIN_STORE}. */ function pluginStore(reads: Partial): PluginStore { - return reads as PluginStore; + // Every real store is built with the provider its rows belong to; the routes read it and never + // guess one, so a stand-in says which deployment it is standing in for. + return { brokerProvider: "composio", ...reads } as PluginStore; } /** diff --git a/server/tests/plugin-servers-validation.test.ts b/server/tests/plugin-servers-validation.test.ts index 7481ee4e3..117c51b61 100644 --- a/server/tests/plugin-servers-validation.test.ts +++ b/server/tests/plugin-servers-validation.test.ts @@ -7,6 +7,7 @@ import type { PluginStore } from "../src/plugins/store"; function appWith(calls: { addServer: unknown[]; addCustomServer: unknown[] }) { const store = { + brokerProvider: "composio" as const, addServer: async (input: unknown) => { // Mirror the real store: it dereferences `input.credentialId?.trim()`, so a // non-string credential id throws a TypeError that used to escape as a 500. @@ -153,6 +154,7 @@ describe("POST /api/plugins/servers/custom", () => { describe("POST /api/plugins/servers/:id/oauth-client", () => { function oauthApp(registered: unknown[]) { const store = { + brokerProvider: "composio" as const, registerOAuthClient: async (input: unknown) => { registered.push(input); }, diff --git a/server/tests/plugins-page-provider.test.ts b/server/tests/plugins-page-provider.test.ts index 2d2c7ac99..cb05c567d 100644 --- a/server/tests/plugins-page-provider.test.ts +++ b/server/tests/plugins-page-provider.test.ts @@ -42,11 +42,23 @@ const UP_TO_BROKER = [ undefined, ] as const; -async function pageFor(composio?: { - broker: unknown; - provider: BrokerProvider; -}) { +async function pageFor( + composio?: { + broker: unknown; + provider: BrokerProvider; + }, + /** + * Which provider the STORE was built for, which is where the page now takes the name from. + * + * The two are the same value on any deployment the wiring builds — but they are not the same + * question, and a deployment with both keys set and `OPENBOT_BROKER_PROVIDER` unset has a store + * and no broker at all. `provider-undecided.test.ts` is that case; here it defaults to the + * Composio deployment these cases are written about. + */ + rowsBelongTo: BrokerProvider = "composio", +) { const store = { + brokerProvider: rowsBelongTo, listServers: async () => [], listSkills: async () => [], } as unknown as PluginStore; @@ -69,10 +81,13 @@ async function pageFor(composio?: { describe("the Plugins page names the active provider", () => { test("an Arcade deployment serves arcade", async () => { - const page = await pageFor({ - broker: { key: VENDOR_KEY }, - provider: "arcade", - }); + const page = await pageFor( + { + broker: { key: VENDOR_KEY }, + provider: "arcade", + }, + "arcade", + ); expect(page.provider).toBe("arcade"); }); @@ -92,10 +107,13 @@ describe("the Plugins page names the active provider", () => { }); test("the name travels, the key and anything derived from it do not", async () => { - const page = await pageFor({ - broker: { key: VENDOR_KEY, grantIsLocal: true }, - provider: "arcade", - }); + const page = await pageFor( + { + broker: { key: VENDOR_KEY, grantIsLocal: true }, + provider: "arcade", + }, + "arcade", + ); expect(Object.keys(page).sort()).toEqual( [ "botsMayCallBack", @@ -148,10 +166,13 @@ describe("the Plugins page declares whether the broker authorizes per action", ( * written from, and the flag says false, because `/servers/:id/authorize` would refuse it 503. */ test("a broker named arcade with no flow serves arcade and false", async () => { - const page = await pageFor({ - broker: { key: VENDOR_KEY, authorizations: null }, - provider: "arcade", - }); + const page = await pageFor( + { + broker: { key: VENDOR_KEY, authorizations: null }, + provider: "arcade", + }, + "arcade", + ); expect(page.provider).toBe("arcade"); expect(page.authorizesPerAction).toBe(false); }); diff --git a/server/tests/provider-undecided.test.ts b/server/tests/provider-undecided.test.ts new file mode 100644 index 000000000..dab94381b --- /dev/null +++ b/server/tests/provider-undecided.test.ts @@ -0,0 +1,163 @@ +// server/tests/provider-undecided.test.ts +/** + * "Which provider" has THREE ways of having no answer, and none of them is "Composio is active". + * + * `activeProvider` answers null when no key is set, when both keys are set and + * `OPENBOT_BROKER_PROVIDER` names neither, and when the variable names a provider whose key is + * unset. The entrypoint and the plugins routes both used to flatten all three with `?? "composio"`, + * so a deployment part-way through a migration — both keys set, variable not yet set, which + * `docs/plugins/arcade.md` calls legal — read its live Arcade rows through Composio's filter and + * Composio's url parser. Those connections showed as absent and could not be revoked. + * + * What is asserted here: the three causes are told apart at the source; a deployment whose + * configuration cannot name a provider takes the name from the rows it actually holds; rows + * belonging to BOTH providers with nothing active is refused rather than resolved; and the plugins + * routes read the one name the stores were built with instead of deciding a second time. + */ +import { describe, expect, test } from "bun:test"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { AccountModeSwitch } from "../src/plugins/account-mode"; +import { providerDecision } from "../src/plugins/broker"; +import { storedProvider } from "../src/plugins/provider-wiring"; +import { createPluginRoutes } from "../src/plugins/routes"; +import type { SharedUseStore } from "../src/plugins/shared-use-store"; +import type { PluginStore } from "../src/plugins/store"; + +describe("the three causes of an undecided provider are told apart", () => { + test("no key at all is its own cause", () => { + expect(providerDecision({})).toEqual({ kind: "unconfigured" }); + }); + + test("one key and no variable is that provider, active", () => { + expect(providerDecision({ arcadeApiKey: "k" })).toEqual({ + kind: "active", + provider: "arcade", + }); + }); + + test("both keys with nothing named is ambiguous, not Composio", () => { + expect( + providerDecision({ composioApiKey: "c", arcadeApiKey: "a" }), + ).toEqual({ kind: "ambiguous", configured: ["composio", "arcade"] }); + }); + + test("a named provider whose key is unset is its own cause", () => { + expect( + providerDecision({ composioApiKey: "c", brokerProvider: "arcade" }), + ).toEqual({ kind: "misnamed", named: "arcade" }); + }); +}); + +describe("a deployment that cannot name a provider takes the name from its rows", () => { + test("both keys, nothing named, Arcade rows: the rows are read as Arcade", () => { + const decision = providerDecision({ + composioApiKey: "c", + arcadeApiKey: "a", + }); + expect(storedProvider(decision, ["arcade"])).toBe("arcade"); + }); + + test("a key withdrawn after the fact still reads its own rows", () => { + expect(storedProvider(providerDecision({}), ["arcade"])).toBe("arcade"); + }); + + test("an active provider is the answer whatever the rows say", () => { + expect( + storedProvider(providerDecision({ composioApiKey: "c" }), ["composio"]), + ).toBe("composio"); + }); + + test("rows at both vendors and nothing active is refused, never resolved", () => { + const decision = providerDecision({ + composioApiKey: "c", + arcadeApiKey: "a", + }); + expect(() => storedProvider(decision, ["composio", "arcade"])).toThrow( + /OPENBOT_BROKER_PROVIDER/, + ); + }); +}); + +/** + * The routes read the name the stores were built with. The case is the migration state exactly: no + * broker is wired (neither key is active), so the `composio` bag the routes take is absent, and the + * store holds Arcade rows. + */ +describe("the plugins routes read one name, not a second answer", () => { + function arcadeDeploymentWithNoBroker() { + const switched: Array<{ serverId: string; mode: string }> = []; + const store = { + brokerProvider: "arcade" as const, + serverAddress: async (serverId: string) => + serverId === "brokered-gmail" + ? { + id: "brokered-gmail", + title: "Gmail", + url: "arcade://Gmail", + authScheme: "OAUTH2", + } + : undefined, + listServers: async () => [], + listSkills: async () => [], + } as unknown as PluginStore; + + const modes = { + switchMode: async (input: { serverId: string; mode: string }) => { + switched.push({ serverId: input.serverId, mode: input.mode }); + return { ended: [], approved: [] }; + }, + forgetRule: async () => {}, + } as unknown as AccountModeSwitch; + + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: "admin-1", + email: "admin@example.test", + role: "admin", + } as never); + await next(); + }; + + const app = new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + async () => true, + undefined, + undefined, + { modes, use: {} as unknown as SharedUseStore }, + ), + ); + return { app, switched }; + } + + test("an Arcade row is read with Arcade's parser though no broker is wired", async () => { + const it = arcadeDeploymentWithNoBroker(); + const response = await it.app.request( + "http://openbot.test/api/plugins/servers/brokered-gmail/account-mode", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "shared" }), + }, + ); + expect(response.status).toBe(200); + expect(it.switched).toEqual([ + { serverId: "brokered-gmail", mode: "shared" }, + ]); + }); + + test("and the page names Arcade rather than Composio", async () => { + const it = arcadeDeploymentWithNoBroker(); + const response = await it.app.request("http://openbot.test/api/plugins"); + expect(response.status).toBe(200); + expect(((await response.json()) as { provider: string }).provider).toBe( + "arcade", + ); + }); +}); diff --git a/server/tests/routes-arcade-urls.test.ts b/server/tests/routes-arcade-urls.test.ts index dd079bb7a..4d0c3ca88 100644 --- a/server/tests/routes-arcade-urls.test.ts +++ b/server/tests/routes-arcade-urls.test.ts @@ -67,6 +67,7 @@ function deployment(provider: BrokerProvider) { const switched: Array<{ serverId: string; mode: string }> = []; const store = { + brokerProvider: provider, serverAddress: async (serverId: string) => serverId === SERVER_ID ? { From a116c7537ea9e9d1c7e7a5ff19581cf986deb532 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:10:00 +0200 Subject: [PATCH 135/232] chore: untrack agent-mastra/bun.lock, swept in by a merge resolution It was untracked before this session and is unrelated to PR #770. A `git add -A` while resolving the A3 merge conflict committed it in b2bc33f3. The file stays on disk exactly as it was; only the tracking is undone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- agent-mastra/bun.lock | 894 ------------------------------------------ 1 file changed, 894 deletions(-) delete mode 100644 agent-mastra/bun.lock diff --git a/agent-mastra/bun.lock b/agent-mastra/bun.lock deleted file mode 100644 index 7d608334e..000000000 --- a/agent-mastra/bun.lock +++ /dev/null @@ -1,894 +0,0 @@ -{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "@openbot/agent-mastra", - "dependencies": { - "@ai-sdk/anthropic": "*", - "@ai-sdk/openai": "*", - "@mastra/core": "*", - "mastra": "*", - }, - }, - }, - "packages": { - "@a2a-js/sdk-v0_3": ["@a2a-js/sdk@0.3.14", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-F6Ew1AtPzCLhTn8h9yiqTe7DiDf6XVrSnq9V1YqSl9eWqPm6anMveTiKdCSb/76cW0YiJc24rNaUrVezFFHbqQ=="], - - "@a2a-js/sdk-v1": ["@a2a-js/sdk@1.0.1", "", { "dependencies": { "jose": "^6.2.3", "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-CJQdh3Wzwo8qIx5UUkSJ7+7BEI16PB+MXMHHNSmx8JQsQed2HlQgvx1ENOiKUfYA3PlcEvxIwv14dBblhDuPmw=="], - - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.75", "", { "dependencies": { "@ai-sdk/provider": "4.0.24", "@ai-sdk/provider-utils": "5.0.56" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4cj+Wf12DnKUAQsQFkk3W5hU3KYP8bGBjdNsji3fmLm1txrgDBQF3urpgltsik8lfUTa6nkKQYOmPrQgPXKMFw=="], - - "@ai-sdk/openai": ["@ai-sdk/openai@4.0.87", "", { "dependencies": { "@ai-sdk/provider": "4.0.24", "@ai-sdk/provider-utils": "5.0.56" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dfadBgux+npOoaPmIP3GGaHtGT6ZpYxBmBQK1TkIkjt/yuEtLxJ0Oklsrm88FTJrTW51qGfLSLeVP7bMUPeMCQ=="], - - "@ai-sdk/provider": ["@ai-sdk/provider@4.0.24", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-iztFqJTdxNN0ZzqZAAnVPMhRMaL1bfQFa3GIPAffWQ0+dP2FXIkyMD8kHeEI7EpB/C6TR45cqOkrS7YZZ1sATA=="], - - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.56", "", { "dependencies": { "@ai-sdk/provider": "4.0.24", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-F7/U1YpVXEfSxRHbxScm0PVdr9lN6GaUWHnt8ws2UjuGeuxWHqCkV25dGpHAkpDHkuIXoC+aak/OqSylKSJmHw=="], - - "@ai-sdk/provider-utils-v6": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], - - "@ai-sdk/provider-utils-v7": ["@ai-sdk/provider-utils@5.0.13", "", { "dependencies": { "@ai-sdk/provider": "4.0.4", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fScDJMDnTbx32kLDQqp0MvPjvwkgiwvlBxlmIg7XW5PbS91LG6JjH3PQG+34oMFglqfpQA355e24OdGj5PPoDw=="], - - "@ai-sdk/provider-v5": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], - - "@ai-sdk/provider-v6": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/provider-v7": ["@ai-sdk/provider@4.0.17", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VYMBxIQdcHqbIf1j+YZlI9Ati6LZ4wJe0GGd4z4a5H/KxTggjeOiyaVYTnfF7LHZK5jMQ+rofmzz4QPqf++NUw=="], - - "@babel/code-frame": ["@babel/code-frame@8.0.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.6", "js-tokens": "^10.0.0" } }, "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw=="], - - "@babel/compat-data": ["@babel/compat-data@8.0.5", "", {}, "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw=="], - - "@babel/core": ["@babel/core@8.0.7", "", { "dependencies": { "@babel/code-frame": "^8.0.6", "@babel/generator": "^8.0.6", "@babel/helper-compilation-targets": "^8.0.7", "@babel/helpers": "^8.0.7", "@babel/parser": "^8.0.7", "@babel/template": "^8.0.0", "@babel/traverse": "^8.0.7", "@babel/types": "^8.0.6", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^3.0.0", "verkit": "^0.5.0" } }, "sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA=="], - - "@babel/generator": ["@babel/generator@8.0.6", "", { "dependencies": { "@babel/parser": "^8.0.6", "@babel/types": "^8.0.6", "@jridgewell/gen-mapping": "0.4.0-beta.0", "@jridgewell/trace-mapping": "^0.3.31", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng=="], - - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@8.0.7", "", { "dependencies": { "@babel/compat-data": "^8.0.5", "@babel/helper-validator-option": "^8.0.0", "browserslist": "^4.24.0", "flru": "1.0.2", "verkit": "^0.5.0" } }, "sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg=="], - - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@8.0.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^8.0.0", "@babel/helper-member-expression-to-functions": "^8.0.5", "@babel/helper-optimise-call-expression": "^8.0.0", "@babel/helper-replace-supers": "^8.0.1", "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", "@babel/traverse": "^8.0.7", "verkit": "^0.5.0" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-7ug3KrOhNVPPxFFLTel0/pMzimKzU/vTlNJKVL/dKguCpmNJZjjibyFbWg+Qs8QHmiIZPNuZ2UNnDGdHWGEpOg=="], - - "@babel/helper-globals": ["@babel/helper-globals@8.0.6", "", {}, "sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA=="], - - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@8.0.5", "", { "dependencies": { "@babel/traverse": "^8.0.5", "@babel/types": "^8.0.5" } }, "sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@8.0.0", "", { "dependencies": { "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@8.0.6", "", { "dependencies": { "@babel/helper-module-imports": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.6", "@babel/traverse": "^8.0.6" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ=="], - - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@8.0.1", "", { "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g=="], - - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@8.0.1", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^8.0.0", "@babel/helper-optimise-call-expression": "^8.0.0", "@babel/traverse": "^8.0.0" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@8.0.0", "", { "dependencies": { "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.6", "", {}, "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.6", "", {}, "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@8.0.0", "", {}, "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q=="], - - "@babel/helpers": ["@babel/helpers@8.0.7", "", { "dependencies": { "@babel/template": "^8.0.0", "@babel/types": "^8.0.6" } }, "sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w=="], - - "@babel/parser": ["@babel/parser@8.0.7", "", { "dependencies": { "@babel/types": "^8.0.6" }, "bin": "./bin/babel-parser.js" }, "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA=="], - - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@8.0.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^8.0.1" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg=="], - - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@8.0.1", "", { "dependencies": { "@babel/helper-module-transforms": "^8.0.1", "@babel/helper-plugin-utils": "^8.0.1" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA=="], - - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@8.0.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^8.0.0", "@babel/helper-create-class-features-plugin": "^8.0.6", "@babel/helper-plugin-utils": "^8.0.1", "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", "@babel/plugin-syntax-typescript": "^8.0.3" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw=="], - - "@babel/preset-typescript": ["@babel/preset-typescript@8.0.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^8.0.1", "@babel/helper-validator-option": "^8.0.0", "@babel/plugin-transform-modules-commonjs": "^8.0.1", "@babel/plugin-transform-typescript": "^8.0.6" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q=="], - - "@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/traverse": ["@babel/traverse@8.0.7", "", { "dependencies": { "@babel/code-frame": "^8.0.6", "@babel/generator": "^8.0.6", "@babel/helper-globals": "^8.0.6", "@babel/parser": "^8.0.7", "@babel/template": "^8.0.0", "@babel/types": "^8.0.6", "obug": "^3.0.0" } }, "sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg=="], - - "@babel/types": ["@babel/types@8.0.6", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.6", "@babel/helper-validator-identifier": "^8.0.6" } }, "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A=="], - - "@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="], - - "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], - - "@expo/devcert": ["@expo/devcert@1.2.2", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A=="], - - "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], - - "@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="], - - "@hono/node-ws": ["@hono/node-ws@1.3.1", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.11", "hono": "^4.6.0" } }, "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="], - - "@hono/standard-validator": ["@hono/standard-validator@0.4.0", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=4.11.2" } }, "sha512-MxOm1asDh9j7c1D0KiWwppSbFgAQxTRO4YEhjMrJn3lF1BIcXenPdgjSGKDRfQmZdaTaMA8slQLETgVOKyJxFw=="], - - "@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.5", "", {}, "sha512-VwGZqqjAWPICTmxUZnbpEfO60LhPWzquik+bmyXGY7pYRn6diEvCI5i6Ca+J6o2y4vS73HrpuMTo2dOvUevH8w=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.4.0-beta.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], - - "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], - - "@mastra/core": ["@mastra/core@1.75.0", "", { "dependencies": { "@a2a-js/sdk-v0_3": "npm:@a2a-js/sdk@~0.3.14", "@a2a-js/sdk-v1": "npm:@a2a-js/sdk@~1.0.1", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.40", "@ai-sdk/provider-utils-v7": "npm:@ai-sdk/provider-utils@5.0.13", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.14", "@ai-sdk/provider-v7": "npm:@ai-sdk/provider@4.0.17", "@isaacs/ttlcache": "^2.1.5", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.3.13", "@standard-schema/spec": "^1.1.0", "chat": "^4.37.0", "croner": "^10.0.1", "dotenv": "^17.3.1", "execa": "^9.6.1", "fastq": "^1.20.1", "gray-matter": "^4.0.3", "hono": "^4.13.7", "hono-openapi": "^1.3.1", "ignore": "^7.0.5", "jpeg-js": "^0.4.4", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", "posthog-node": "^5.46.1", "re2js": "^2.8.6", "ws": "^8.21.3", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1yw89tyha4HU1eO34IC5sYdSCbXuw8WCVf4+pqmNyRmoHp0ZcPzyberdsbq9O8CTHuxKZbf7cgsI2QNI2L3SRA=="], - - "@mastra/deployer": ["@mastra/deployer@1.75.0", "", { "dependencies": { "@babel/core": "^8.0.1", "@babel/preset-typescript": "^8.0.1", "@babel/traverse": "^8.0.4", "@hono/node-ws": "^1.3.0", "@mastra/server": "1.75.0", "@optimize-lodash/rollup-plugin": "^5.1.0", "@rollup/plugin-alias": "6.0.0", "@rollup/plugin-commonjs": "29.0.2", "@rollup/plugin-esm-shim": "0.1.8", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-node-resolve": "16.0.3", "@rollup/plugin-virtual": "3.0.2", "@sindresorhus/slugify": "^2.2.1", "empathic": "^2.0.0", "esbuild": "^0.28.0", "find-up-simple": "^1.0.1", "find-workspaces": "^0.3.1", "fs-extra": "^11.3.5", "gray-matter": "^4.0.3", "hono": "^4.13.7", "local-pkg": "^1.1.2", "resolve.exports": "^2.0.3", "rollup": "^4.61.1", "rollup-plugin-esbuild": "^6.2.1", "strip-json-comments": "^5.0.3", "tinyglobby": "^0.2.17", "typescript-paths": "^1.5.2", "ws": "^8.21.3", "yaml": "^2.9.0" }, "peerDependencies": { "@mastra/core": ">=1.58.0-0 <2.0.0-0" } }, "sha512-6rJ11ZrYcDnt+WxkJMuZyAAUjrqrK/gDdv30+1tQBvtYcgHDfeuIW6IKbYfowU3C/EhqQtLPDAPlm4OlPQKKhQ=="], - - "@mastra/loggers": ["@mastra/loggers@1.3.5", "", { "dependencies": { "pino": "^10.3.1", "pino-pretty": "^13.1.3" }, "peerDependencies": { "@mastra/core": ">=1.0.0-0 <2.0.0-0" } }, "sha512-wGI2xle5VWAvRLXAbT2kzOlUovy5rRD89l0HoPNN12a2KU7QfLCX686KqCD0SrzVAzHENYHrgNm6Ek1lSSCkEg=="], - - "@mastra/schema-compat": ["@mastra/schema-compat@1.3.13", "", { "dependencies": { "json-schema-to-zod": "^2.7.0", "zod-from-json-schema": "^0.5.2" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-L3AkWcujcCC9qKYiGOxjRN0NrNMIxLAwn/ayjNlD3zhFSUBDI45LmlFxAYHLeAz315uogKb+9tZA/TjHnjb95w=="], - - "@mastra/server": ["@mastra/server@1.75.0", "", { "dependencies": { "hono": "^4.13.7" }, "peerDependencies": { "@mastra/core": ">=1.58.0-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-yFhGn8vfGjXCfVkJJOhh9psTk5PrI7nVyEjeFao0L+dOXD5smsZ+to5WqL7UpasIm02dBLQxmJ5IqNTuoZacdw=="], - - "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], - - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - - "@optimize-lodash/rollup-plugin": ["@optimize-lodash/rollup-plugin@5.1.0", "", { "dependencies": { "@optimize-lodash/transform": "3.0.6", "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": ">= 4.x" } }, "sha512-dBQYGH8+n4Z/877e61PJteVZEc+U1wfRF6IhKqW0cABRIsqMxpWynyov6M6Sd4IUjru0dnh0EG55X86JO6WakQ=="], - - "@optimize-lodash/transform": ["@optimize-lodash/transform@3.0.6", "", { "dependencies": { "estree-walker": "^2.0.2", "magic-string": "~0.30.11" } }, "sha512-9+qMSaDpahC0+vX2ChM46/ls6a5Ankqs6RTLrHSaFpm7o1mFanP82e+jm9/0o5D660ueK8dWJGPCXQrBxBNNWA=="], - - "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - - "@posthog/core": ["@posthog/core@1.57.2", "", { "dependencies": { "@posthog/types": "^1.415.1" } }, "sha512-dfxeSjUvR+j4M7odvyf/KVdcKTZZ87u/l9swJtDt9vFkI2km3RhEveOkIHtFcZEQ1cllx7YaKJfdw6c8zfIEvg=="], - - "@posthog/types": ["@posthog/types@1.415.1", "", {}, "sha512-7Qs2wcqipl8KcFpIkIKE1Wcj1djX4dEB3cfq907HrNpKK7/xKh3U4N4bkOD6h9+8j3SxWWrj9JNpE8CZXsE35w=="], - - "@rollup/plugin-alias": ["@rollup/plugin-alias@6.0.0", "", { "peerDependencies": { "rollup": ">=4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g=="], - - "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg=="], - - "@rollup/plugin-esm-shim": ["@rollup/plugin-esm-shim@0.1.8", "", { "dependencies": { "magic-string": "^0.30.3", "mlly": "^1.7.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-xEU0b/BShgDDSPjidhJd4R74J9xZ9jLVtFWNGtsUXyEsdwwwB1a3XOAwwGaNIyUHD6EhxPO21JMfUmJWoMn7SA=="], - - "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="], - - "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="], - - "@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="], - - "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.2", "", { "os": "android", "cpu": "arm" }, "sha512-gjNDUwMswogkw3fNqV00u54uZlgmInuw5EU/1TeqQ6K0bEagoGvqjPdKzy112lKyhe7PkGudZ/kSQHsc9qroIg=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YkAW8q7p5SCLSaGWaUkmsRnBEb3MGA1pB5nI/8k0lAK5fZsQKRBqkdR1h4UU9p8u3c7ZGdUF2Zcx7mK+E/pUKg=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hNmuIHv+LnrP/+q/44vsmIMWfoh7X2YXOp5FLtjGuvqN52VdtFpOpnshTc1k28tWv1wE/0D5gLjsozBUowEGbQ=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-B0t5CoxgF2KL7R5FHAsyN8yVEQCSIkh4XVs8MCDjuBLNjEa8l3zd5tVKaFMv53LfUtHs9p2rnqeavPTOunV+Ww=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-QQdYnfwrPmc1DoPlddjFvQPzUHFbStKXsHrG6EBYsWHhBVUCjsCRdcfGGXGAvhDpDtTzKK4vy1UrGFJ45rQaiw=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1xRxtGWif6BB58VSfMavi0Yfs2M4x6CtRp4o7ScY4W7AGlgvf6GSPKdDNdw7TX3tMiQwZY5W/mvyrOJTq/7AKA=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.2", "", { "os": "linux", "cpu": "arm" }, "sha512-CXFLEt384BUwwOLR9DURWNHl1bQOVLd1ibOee+yKBJm/fZtvrSAOZ0JbxjAxszVMIovYMwFMS+oP+Q1aPcx8Eg=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.2", "", { "os": "linux", "cpu": "arm" }, "sha512-T1dorY0WHm5mMNpMHFl5rYRZ02gLSZcr6J45r66tYb3S3R3DyIRGiGWVdLtF+zOQjratNJaObl9jYumueRiL1A=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-ia6bfaCIc0ldp6bvs9oEkFrJVJKNUMoiDF0+LBZsQAZg+vE0znXnzGqH+ew/FYswE30cruAWPTO3dPw7keaVkQ=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-mB/gwfBfXtT0EBpbSssqKCqMFuX8b21Zy2TGoIhehSJEssZ5ksybxymwp1BkEM5Nczh/AAxpmh5q+MkhtundGg=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-VPd0a+XlHoNRI9jIPRHww2UKhDkD6XjbtcKYXZijAnhJS1jLLbiNtVl6a5LyEfNN+eyiyWcJOZXPzBo69oTsew=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-10N1I6oBpERqzGtHKcCLrEpHlbXSBbMK9TURHvqgWbIFZuU3oJROxJ0H2nHfq96TVI0XJHQ9qzolJMFd2tz0LA=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-dhJrLVZ3wNNhwCmy9/VTg6Pq6J8a8eZ0n15dVRlbSWcDb+8V/1mDfbnvis+vt8W6Pbxzv6gUbTEl4piOT3bbUQ=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tPd2QuwxkTxgju5eARyb3RdgBWWi/O9dn/NFH0VkvGbLzoQeXrH6qMABtW8IODb5lUeBzB3sy2U7qmI4MtQ72g=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-njtDdPvSQWI9CDhIPPxsiEMeHayCfu7wwcbSQ7c2kYPhGVviL+WGVhq6Jfj/b+VuhSTkFuQ0z4ZFbFjkWwBFmg=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.2", "", { "os": "linux", "cpu": "none" }, "sha512-EbOBo3dqCj9ecEV9Z7zvulB6AieMP2ztnKQYGVOSN0pzIyOu+3JH2YJZnLmbPeVvfn6yP6Qi7NbBM27EzGbj2w=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-8wLt2Q5n4qmfiE2Gk8V6KNLSf+oJuBX7Kdw5joFDtnZIZvirS0hLzBc79OiJjV71oYDmBUZLNy6pDgvylo4qiQ=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.2", "", { "os": "linux", "cpu": "x64" }, "sha512-aIChbkhC4d33bxkVP0LSi2/2lUSGRXE6k4Qa0G7tgpgTOK7kxHTixmx5qC3SKUJ6yUMznHBtQoWaG4QMqLUnhw=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.2", "", { "os": "linux", "cpu": "x64" }, "sha512-gQ+TkVZRfjZVcl/UOAxxfWw+sHjzIXoieNwxEyJlD3tRBmBhR8ZDq11w+QEgQDcpslEB+fhN/OEpaTsDoIIHhQ=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-sUaJpMKS2JBCvUk8fYimG7aMq2jHVT4kw/uVHxPolKLrMmkKy0KXirmZyZehdNpL7PffqAlPAWUELQcTiD8ChA=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.2", "", { "os": "none", "cpu": "arm64" }, "sha512-j1RdJWb0CrlSuwt12OPjLucPlwfLtA/KMOKVvh5rXSJEtl/WDoIGR3CSykK/jLGTo9RqNfW5Ub6kY5Z5ccYl9w=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-i3r04aHCIY3eggTBLUp/4IxiBjDpAgQaK89Ue2nMJ2MKuIXX5JfoeZjHw+kPjGGNVDlhi8FT8xzuGMJZe3aehg=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-kCQ15h/uwNMGdN8gjXBZGf+aGE+ODzHfbi90/cAm4+Q89WoMiLODNoj8HoGZmYx87HPxfu5CBNPsZ+ZG4uIAoA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.2", "", { "os": "win32", "cpu": "x64" }, "sha512-yYAuySSs+0X2gSMJPw/Q4jHsHZHyMq1dpAE+XnN790Jg1WuReUAD/+j+e4vuDvS4PXJ4PA4NCzTR4OYtDf18/g=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.2", "", { "os": "win32", "cpu": "x64" }, "sha512-JY1rQdWOJrKutORqKO1uzrHXV86jFiQzfLFKx2s8S1CjLXL9fC5LOIvk0q7Z7PXJvsByt+sdoE1Q2a9gBr4gLQ=="], - - "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - - "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - - "@sindresorhus/slugify": ["@sindresorhus/slugify@2.2.1", "", { "dependencies": { "@sindresorhus/transliterate": "^1.0.0", "escape-string-regexp": "^5.0.0" } }, "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw=="], - - "@sindresorhus/transliterate": ["@sindresorhus/transliterate@1.6.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ=="], - - "@standard-community/standard-json": ["@standard-community/standard-json@0.3.6", "", { "dependencies": { "quansync": "^0.2.11" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.20.0", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.4.2", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-tuXKz1Ps4al0W4xhfwii7v4lskKtOv3QE7mcouRVThrBdOR6JzY4Vea3nsxcEnte84bOJvMvXGmOMWE3IBbHyA=="], - - "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.10", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.20.0", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.4.2", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-whaCN9eu5zv5gu6Kyeh5am/S3AyS1nqo57/wAxs/KPS7+94UhBUZmkIetmXzm22qIV0zMrHt3y+2VfjkbkYYTA=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - - "@types/gensync": ["@types/gensync@1.0.5", "", {}, "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg=="], - - "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - - "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], - - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - - "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], - - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - - "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], - - "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="], - - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - - "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], - - "b4a": ["b4a@1.9.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ=="], - - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "bare-events": ["bare-events@2.9.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA=="], - - "bare-fs": ["bare-fs@4.8.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-+ZI68KHMUvosXfKbg/UOHK0tbCdRnegbvPEdEcZ3Nd6TetieQsJPRXBRXPdLyy8+3VSEbPXtsumTpEtt78xv9w=="], - - "bare-path": ["bare-path@3.1.2", "", {}, "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw=="], - - "bare-stream": ["bare-stream@2.13.4", "", { "dependencies": { "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA=="], - - "bare-url": ["bare-url@2.5.4", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="], - - "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - - "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], - - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - - "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], - - "bytes": ["bytes@3.0.0", "", {}, "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], - - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - - "chat": ["chat@4.41.1", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182 || ^7.0.0", "workflow": "^5.0.0-beta.35", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "workflow", "zod"] }, "sha512-+/LDcLoYLihf/9Kp5ymj2ylvm6q5vySQTMn0ZoeU0UWDfNVmCNc6OiADW8hwnejOLfevUs8lEpgotG9rmIxZvg=="], - - "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - - "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], - - "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], - - "content-disposition": ["content-disposition@0.5.2", "", {}, "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - - "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], - - "crc32-stream": ["crc32-stream@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g=="], - - "croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], - - "debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - - "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - - "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - - "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.451", "", {}, "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg=="], - - "empathic": ["empathic@2.1.0", "", {}, "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw=="], - - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - - "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - - "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], - - "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], - - "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], - - "exsolve": ["exsolve@1.1.3", "", {}, "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA=="], - - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - - "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - - "fast-copy": ["fast-copy@4.1.2", "", {}, "sha512-DUHggaYcouhy5I1LacDJ+JDSqGlumVJP2EAi5Nwt1pWPeLpSvIjkrY+x4fsoI1/+GlTyq+DM9zdgWCFSfqNODA=="], - - "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], - - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - - "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], - - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], - - "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], - - "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - - "find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="], - - "find-workspaces": ["find-workspaces@0.3.1", "", { "dependencies": { "fast-glob": "^3.3.2", "pkg-types": "^1.0.3", "yaml": "^2.3.4" } }, "sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w=="], - - "flru": ["flru@1.0.2", "", {}, "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog=="], - - "fs-extra": ["fs-extra@11.4.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="], - - "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - - "get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="], - - "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], - - "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - - "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], - - "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], - - "hono-openapi": ["hono-openapi@1.3.5", "", { "dependencies": { "@hono/standard-validator": "^0.4.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "openapi-types": "^12.1.3" }, "peerDependencies": { "hono": "^4.11.2" } }, "sha512-t1YpXno9oXa6400xi9rX6i1jKB0496VvikcVKpN/UY3s7yDDQqeCgnKlDr3rfkqxGvykq3+bgX/64eCm7UnUMA=="], - - "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "ignore": ["ignore@7.0.12", "", {}, "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg=="], - - "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="], - - "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], - - "is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - - "is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], - - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], - - "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], - - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], - - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], - - "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "js-yaml": ["js-yaml@3.15.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - - "json-schema-to-zod": ["json-schema-to-zod@2.8.1", "", { "bin": { "json-schema-to-zod": "dist/cjs/cli.js" } }, "sha512-fRr1mHgZ7hboLKBUdR428gd9dIHUFGivUqOeiDcSmyXkNZCtB1uGaZLvsjZ4GaN5pwBIs+TGIOf6s+Rp5/R/zA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], - - "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], - - "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], - - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - - "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - - "mastra": ["mastra@1.33.0", "", { "dependencies": { "@babel/parser": "^8.0.4", "@babel/types": "^8.0.4", "@clack/prompts": "^1.7.0", "@expo/devcert": "^1.2.1", "@mastra/deployer": "^1.75.0", "@mastra/loggers": "^1.3.5", "archiver": "^8.0.0", "commander": "^14.0.3", "dotenv": "^17.3.1", "execa": "^9.6.1", "fs-extra": "^11.3.5", "get-port": "^7.1.0", "local-pkg": "^1.1.2", "openapi-fetch": "^0.17.0", "picocolors": "^1.1.1", "posthog-node": "^5.46.1", "semver": "^7.7.4", "serve-handler": "^6.1.7", "strip-json-comments": "^5.0.3", "tinyglobby": "^0.2.17", "yocto-spinner": "^1.1.0" }, "peerDependencies": { "@mastra/core": ">=1.58.0-0 <2.0.0-0" }, "bin": { "mastra": "./dist/index.js" } }, "sha512-m1R3Y8vOfVLclqMCM0VzPsgG7EvYWpLNr9TmL6ZEN4eIKXvHX2tyvQUzKzalgaLD2MffhIl7NYtitT2AGDMIww=="], - - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw=="], - - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="], - - "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], - - "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], - - "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], - - "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], - - "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], - - "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - - "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - - "mdast-util-to-markdown": ["mdast-util-to-markdown@2.2.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA=="], - - "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - - "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], - - "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], - - "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], - - "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], - - "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], - - "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], - - "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], - - "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], - - "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - - "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], - - "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], - - "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], - - "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], - - "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], - - "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], - - "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], - - "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], - - "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], - - "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], - - "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], - - "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], - - "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], - - "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], - - "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], - - "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], - - "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], - - "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], - - "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], - - "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "mime-db": ["mime-db@1.33.0", "", {}, "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="], - - "mime-types": ["mime-types@2.1.18", "", { "dependencies": { "mime-db": "~1.33.0" } }, "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ=="], - - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="], - - "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - - "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], - - "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], - - "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="], - - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - - "openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="], - - "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], - - "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], - - "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - - "path-is-inside": ["path-is-inside@1.0.2", "", {}, "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "path-to-regexp": ["path-to-regexp@3.3.0", "", {}, "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - - "pino": ["pino@10.4.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^1.0.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA=="], - - "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], - - "pino-pretty": ["pino-pretty@13.2.0", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.2", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.3", "secure-json-parse": "^4.1.0", "sonic-boom": "^5.0.0", "strip-json-comments": "^5.0.3" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg=="], - - "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], - - "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], - - "posthog-node": ["posthog-node@5.55.0", "", { "dependencies": { "@posthog/core": "^1.55.2" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-8dFtlDTqs0CKGZ8t4mUnk4NdShAgzru3B9D0MfM/NTBzlRyAjctivzWINLtMLRQxYjvgBohxy7RTwiQf4mPycQ=="], - - "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], - - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], - - "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], - - "process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="], - - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - - "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], - - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - - "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], - - "range-parser": ["range-parser@1.2.0", "", {}, "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A=="], - - "re2js": ["re2js@2.8.6", "", {}, "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg=="], - - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - - "readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="], - - "real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], - - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - - "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], - - "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - - "remend": ["remend@1.4.0", "", {}, "sha512-mHiYhhwphs1IgIZmkToKLwJn0U99IjPXuutkA2acrNcG0vlEq2G4TGnd2eT0saIHwh2KjaCpvleHcsd5SAz96A=="], - - "resolve": ["resolve@1.22.13", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.17.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="], - - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - - "rollup": ["rollup@4.64.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.2", "@rollup/rollup-android-arm64": "4.64.2", "@rollup/rollup-darwin-arm64": "4.64.2", "@rollup/rollup-darwin-x64": "4.64.2", "@rollup/rollup-freebsd-arm64": "4.64.2", "@rollup/rollup-freebsd-x64": "4.64.2", "@rollup/rollup-linux-arm-gnueabihf": "4.64.2", "@rollup/rollup-linux-arm-musleabihf": "4.64.2", "@rollup/rollup-linux-arm64-gnu": "4.64.2", "@rollup/rollup-linux-arm64-musl": "4.64.2", "@rollup/rollup-linux-loong64-gnu": "4.64.2", "@rollup/rollup-linux-loong64-musl": "4.64.2", "@rollup/rollup-linux-ppc64-gnu": "4.64.2", "@rollup/rollup-linux-ppc64-musl": "4.64.2", "@rollup/rollup-linux-riscv64-gnu": "4.64.2", "@rollup/rollup-linux-riscv64-musl": "4.64.2", "@rollup/rollup-linux-s390x-gnu": "4.64.2", "@rollup/rollup-linux-x64-gnu": "4.64.2", "@rollup/rollup-linux-x64-musl": "4.64.2", "@rollup/rollup-openbsd-x64": "4.64.2", "@rollup/rollup-openharmony-arm64": "4.64.2", "@rollup/rollup-win32-arm64-msvc": "4.64.2", "@rollup/rollup-win32-ia32-msvc": "4.64.2", "@rollup/rollup-win32-x64-gnu": "4.64.2", "@rollup/rollup-win32-x64-msvc": "4.64.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RxXm0XVVV1hFI3dYe12ruaOgsYQ725lUcUWeMSQqkF9tTxVErY+fy2Ets6vWhItL9MZMSNZGbvZFc7rH/VHp2Q=="], - - "rollup-plugin-esbuild": ["rollup-plugin-esbuild@6.2.1", "", { "dependencies": { "debug": "^4.4.0", "es-module-lexer": "^1.6.0", "get-tsconfig": "^4.10.0", "unplugin-utils": "^0.2.4" }, "peerDependencies": { "esbuild": ">=0.18.0", "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" } }, "sha512-jTNOMGoMRhs0JuueJrJqbW8tOwxumaWYq+V5i+PD+8ecSCVkuX27tGW7BXqDgoULQ55rO7IdNxPcnsWtshz3AA=="], - - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], - - "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], - - "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], - - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "serve-handler": ["serve-handler@6.1.7", "", { "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" } }, "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - - "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], - - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - - "streamx": ["streamx@2.28.1", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - - "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], - - "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - - "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "tar-stream": ["tar-stream@3.2.2", "", { "dependencies": { "b4a": "^1.9.0", "bare-fs": "^4.8.2", "fast-fifo": "^1.3.2", "streamx": "^2.28.1" } }, "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg=="], - - "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], - - "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], - - "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], - - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - - "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - - "typescript-paths": ["typescript-paths@1.5.2", "", { "peerDependencies": { "typescript": "^4.7.2 || ^5 || ^6" } }, "sha512-s5iiRIWOSw80dBgPACm0asPQZHHQcbPz69f26eRq01dStusuD11idZ0NDcAbkj6WuUGcRwJediPOsrArIS92eg=="], - - "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], - - "undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], - - "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], - - "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], - - "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - - "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - - "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "unplugin-utils": ["unplugin-utils@0.2.5", "", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.3" } }, "sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg=="], - - "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], - - "verkit": ["verkit@0.5.0", "", {}, "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g=="], - - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], - - "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], - - "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], - - "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], - - "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], - - "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], - - "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], - - "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - - "zod-from-json-schema": ["zod-from-json-schema@0.5.6", "", { "dependencies": { "zod": "^4.0.17" } }, "sha512-U33AJ7ZWS6y9XNSzMWcdy8hRAvZmWhTtpYJu0SXPT5AArbc9nq2ur7Magzmn5RF9KBV4b3FP0nCpmqqlfXlR9w=="], - - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - - "@ai-sdk/provider-utils-v6/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/provider-utils-v7/@ai-sdk/provider": ["@ai-sdk/provider@4.0.4", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ=="], - - "chat/@workflow/serde": ["@workflow/serde@4.1.0-beta.2", "", {}, "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww=="], - - "find-workspaces/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - - "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - - "micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - - "pino-pretty/sonic-boom": ["sonic-boom@5.0.2", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-/JEruzo6yhNVhsLblTRchgTQHIt7SsdJP0YyrfWaNKjIOQDfrdZv9gwYraYCAsmKV39/LAdV31FLL5NCDpoL/A=="], - - "readdir-glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], - - "rollup-plugin-esbuild/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "find-workspaces/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - - "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - - "readdir-glob/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], - - "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - } -} From a02eddeecd9ec3fad8d4266f535e8860ab7b1e73 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:21:45 +0200 Subject: [PATCH 136/232] fix(arcade): a failed clean-up must not replace the refusal with a bodyless 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The definite-failure branch of `POST /servers/:id/authorize` called `store.discardAuthorizationAttempt` unguarded. The handler had already worked out what to answer — which vendor sentence to pass on, and whether it was a 502 or an authored 503 — and a fault on that tidy-up delete threw past the handler, so the person received a bodyless 500 instead. The least informative possible answer, in exactly the case where the route had something specific and true to tell them, with the error that mattered destroyed by the failure of the clean-up for it. The discard now runs in its own `try`, logs `arcade-refused-attempt-not-cleared` (read through `reasonWithoutStatement`, so a `DrizzleQueryError`'s statement and bound values never reach stdout) and the chosen refusal is answered either way. This is the shape the record-after-mint branch immediately below already uses. Where the discard did not happen, the attempt stands with a null `auth_id` and holds the actor until its TTL, which makes the generic sentence's "try again in a moment" false — so the same clause that branch answers with is APPENDED. The vendor's own sentence is the half that says why, and it is not thrown away to make room for ours. Audit of the whole file for the same pattern: `routes.ts` contains exactly two awaited calls inside a `catch`, this one and the already-guarded discard below it. The verify leg's writes (`failAuthorizationAttempt`, `markAuthorizationWithdrawn`/`markAuthorizationUnreconciled`, `completeAuthorizationAttempt`) sit under an outer `try` that answers `refusedWith("not-recorded")`, and the OAuth callback's `recordConnection` is already wrapped and redirects. No other instance. Call sites: no symbol was added, renamed, resignatured or removed. The only changed behaviour is the response body of the definite-failure branch when the discard does not delete the row, and its status, which is now the refusal's rather than 500. Red (unfixed) for the new case, verbatim: error: Failed query: delete from pending_authorizations at .../server/src/plugins/routes.ts:2719:21 error: expect(received).toBe(expected) Expected: 502 Received: 500 Green: 21 pass / 0 fail in arcade-authorize-route.test.ts; 66 pass / 0 fail across the five arcade route and store suites. `bun run typecheck` exits 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/routes.ts | 53 +++++++++++++++++++-- server/tests/arcade-authorize-route.test.ts | 22 +++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index d5f8ee5ec..7c2b1dbad 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2716,15 +2716,58 @@ export function createPluginRoutes( const definite = authored || (failure !== null && failure.status !== undefined); if (definite) { - await store.discardAuthorizationAttempt({ - actorId: context.var.actor.id, - createdAt: claim.attempt.createdAt, - }); + /* + * THE CLEAN-UP IS GUARDED, FOR THE SAME REASON THE RECORD-FAILURE CLEAN-UP BELOW IS. + * + * This discard is a tidy-up for a refusal the handler has ALREADY WORKED OUT — which vendor + * sentence to pass on, and whether it is a 502 or an authored 503. Unguarded, a fault on the + * delete threw past the handler and the person got a BODYLESS 500 instead: the least + * informative answer possible, in exactly the case where this route had something specific + * and true to tell them, and the failure of a clean-up destroyed the error it was cleaning + * up after. The refusal is the answer; whether the row went with it is not the person's + * problem to be given a blank page over. + * + * WHAT A FAILED DISCARD COSTS THEM IS SAID RATHER THAN GUESSED AT. The attempt stands with a + * null `auth_id`, so it can never complete and `claimAuthorizationAttempt` refuses a second + * start until it is fifteen minutes old — which makes the generic sentence's "try again in a + * moment" false. The extra clause is the same one the record-failure branch answers with for + * the same condition, and it is added rather than substituted: the vendor's own sentence is + * the half that says WHY, and it is not thrown away to make room for ours. + * + * The error is read through {@link reasonWithoutStatement}, as every database failure logged + * on this route is: a `DrizzleQueryError`'s own message is the statement and every value + * bound to it. + */ + let freed = false; + try { + freed = await store.discardAuthorizationAttempt({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + }); + } catch (cleanup) { + console.error( + JSON.stringify({ + type: "arcade-refused-attempt-not-cleared", + server: serverId, + action, + note: "A mint was refused and its attempt could not be deleted. The person was told why the vendor refused, and the row holds them until it expires.", + error: reasonWithoutStatement(cleanup), + }), + ); + freed = false; + } const refusal = brokerRefusal( error, "The app's vendor refused to start that authorization, so nothing was started. Try again in a moment.", ); - return context.json({ error: refusal.error }, refusal.status); + return context.json( + { + error: freed + ? refusal.error + : `${refusal.error} The attempt could not be cleared either; it expires within fifteen minutes, and starting over works after that.`, + }, + refusal.status, + ); } return context.json( { diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index d2f747b79..a97c6809a 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -409,6 +409,28 @@ describe("POST /servers/:id/authorize", () => { expect(survivor?.createdAt.getTime()).toBe(newer?.createdAt.getTime()); }); + test("a definite refusal whose clean-up also fails still answers the refusal, not a bodyless 500", async () => { + const actor = person("definite-stuck"); + beforeMint = refuse; + const response = await authorize( + appAs("user", actor, "arcade", { + discardAuthorizationAttempt: async () => { + throw new Error("Failed query: delete from pending_authorizations"); + }, + }), + personalApp, + ); + // The refusal the handler worked out, not the generic 500 a thrown clean-up produces. + expect(response.status).toBe(502); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + expect(JSON.stringify(body)).not.toContain("vendor-body-secret"); + expect(JSON.stringify(body)).not.toContain("Failed query"); + // Nothing cleared it, so the row stands and expires on its own. + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ authId: null, completedAt: null }); + }); + test("an authored refusal is definite: nothing reached the vendor, so the attempt is deleted", async () => { const actor = person("authored"); const { BrokerRefusalError } = await import("../src/plugins/broker"); From 51b79bb0a00f9b02e84c93ec9f05483e1eb363d4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:22:01 +0200 Subject: [PATCH 137/232] fix(arcade): cap the text callTool puts in a model's context and the audit row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `callTool` returned `JSON.stringify(output.value)` whole, with `truncated: false` hardcoded on every path. So the field that exists to say "there was more" could not ever say it, and an unbounded vendor answer — whatever the mailbox, issue or document the action read happens to contain, which is attacker-influenceable — went straight into a model's context window and into the audit row that is supposed to be a durable, bounded record of what happened. Followed `composio.ts`'s `cap()` rather than `mcp.ts`'s `resultText()`: that is the sibling with the same job (a brokered vendor transport stringifying one `unknown` value into an `McpCallResult`), the same limit (`MAX_RESULT_CHARS`, 20_000) and the same marker (`\n\n[truncated]`). `resultText` is about joining MCP content parts and the empty-answer sentence, neither of which this path has. Same-pattern audit of the whole file: the only two `truncated: false` literals in `arcade.ts` were the success path and `failureResult`. Both now go through `cap()`. `failureResult` earns it because every sentence it builds quotes `toolName`, a `qualified_name` the vendor's listing supplied, exactly as composio's `failure` caps its own refusals. Deliberately NOT changed: the listing path's `description` and parameter-description passthrough — neither `mcp.ts` nor `composio.ts` bounds a listed description, and inventing a bound only here would be a third convention, not this finding. A guard was added where the cap made one necessary: `JSON.stringify` answers `undefined` for a value with no JSON form (a function, a symbol), `??` does not catch it since a function is not nullish, and `cap(undefined)` would throw `text.length` inside the vendor `try` and be reported as "Arcade could not run X" — a sentence about the vendor for our own bug. Named in words instead. Call sites of the symbols touched (all within `server/src/plugins/arcade.ts`; nothing is exported or re-signatured, so no caller outside the file changes): - `cap` (added, module-private): `failureResult`, `callTool` success path. - `failureResult` (unchanged signature, body now spreads `cap`): six call sites, all in `callTool` — unconfigured deployment, no actor named, authorization envelope on a 200, vendor-reported failure, no-JSON-form answer (new), and both catch-path refusals. - `MAX_RESULT_CHARS` / `cutAtCodeUnits`: newly imported here from `./mcp` and `../channels/text`. `./mcp` does not import `./arcade`, so no cycle; the module's "no vendor package" promise is unaffected (`composio.ts` imports the same two). Red (before the fix), `bun test server/tests/arcade-errors.test.ts`: (fail) callTool caps what a model is shown > an enormous result is cut and says it was cut — expect(result.truncated).toBe(true), Received: false (fail) callTool caps what a model is shown > a refusal is capped too — same. 12 pass, 2 fail. Green: 14 pass, 0 fail. `arcade-pagination-engine`, `arcade-malformed-listing- engine`, `arcade-transport`, `arcade-listing`, `arcade-classify`, `arcade-adapter` 45 pass; eight further arcade suites 72 pass / 13 skip / 0 fail. `bun run typecheck` exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade.ts | 47 +++++++++++++++++++++++++----- server/tests/arcade-errors.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 9f03963de..016d2e6b4 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -4,8 +4,9 @@ * records that the other direction is a cycle). */ +import { cutAtCodeUnits } from "../channels/text"; import { flagOf } from "./broker"; -import type { ListedTool, McpCallResult } from "./mcp"; +import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp"; /** * Whether listing tools needs somebody's credential. It does not: the listing uses the deployment's @@ -280,10 +281,31 @@ function arcadeFailure(thrown: unknown): ArcadeFailure | null { }; } +/** + * The cap every string this module puts in front of a model goes through, as `composio.ts`'s + * `cap()` does, with the same limit and the same marker. + * + * A tool result is read by a model AND copied into the audit row, so an unbounded one is somebody + * else's server deciding how much of our context window to spend and how large a durable record + * grows — and the text is attacker-influenceable, since it is whatever the mailbox, issue or + * document the action read happens to contain. Truncated visibly, never silently: `truncated` is + * the field that says it happened, and reporting `false` unconditionally made it a field that + * structurally could not. + * + * BOTH ANSWERS GO THROUGH IT. A refusal lands in a model's context exactly as a result does, and + * this file's refusals quote `toolName`, which is a `qualified_name` the vendor's listing supplied. + */ +function cap(text: string): { text: string; truncated: boolean } { + if (text.length <= MAX_RESULT_CHARS) return { text, truncated: false }; + return { + text: `${cutAtCodeUnits(text, MAX_RESULT_CHARS)}\n\n[truncated]`, + truncated: true, + }; +} + const failureResult = (text: string): McpCallResult => ({ - text, + ...cap(text), isError: true, - truncated: false, }); /** @@ -338,11 +360,20 @@ export async function callTool( if (answer.success === false || answer.output?.error) { return failureResult(`Arcade could not run ${toolName}.`); } - return { - text: JSON.stringify(answer.output?.value ?? null, null, 2), - isError: false, - truncated: false, - }; + const value = JSON.stringify(answer.output?.value ?? null, null, 2); + /* + * `JSON.stringify` ANSWERS `undefined` RATHER THAN THROWING for a value with no JSON form — a + * function, a symbol — and `output.value` is the vendor's field while the `unknown` saying + * anything may be there is ours. `??` does not catch it: a function is not nullish. Named here + * so the refusal is this deployment's sentence rather than a `text.length` TypeError raised + * inside {@link cap} and caught below as if Arcade had failed. + */ + if (value === undefined) { + return failureResult( + `${toolName} ran, but its answer has no JSON form, so there is nothing to show.`, + ); + } + return { ...cap(value), isError: false }; } catch (thrown) { const failure = arcadeFailure(thrown); if (failure && isAuthorizationRequired(failure)) { diff --git a/server/tests/arcade-errors.test.ts b/server/tests/arcade-errors.test.ts index 09a9e6087..d986c592b 100644 --- a/server/tests/arcade-errors.test.ts +++ b/server/tests/arcade-errors.test.ts @@ -6,6 +6,7 @@ import { useArcadeClient, type ArcadeFailure, } from "../src/plugins/arcade"; +import { MAX_RESULT_CHARS } from "../src/plugins/mcp"; afterEach(() => useArcadeClient(null)); @@ -180,3 +181,38 @@ describe("callTool successes", () => { expect(result.text).not.toContain("duration"); }); }); + +/** + * A tool result is read by a model and copied into the audit row, so an unbounded one is the vendor + * — or whoever wrote the mailbox it read — deciding how much of our context window to spend and how + * large a durable record grows. `composio.ts`'s `cap()` is the convention this follows: BOTH answers + * go through it, the result and the refusal, and `truncated` is the field that says it happened. + */ +describe("callTool caps what a model is shown", () => { + test("an enormous result is cut and says it was cut", async () => { + const enormous = "x".repeat(MAX_RESULT_CHARS + 5_000); + answering({ success: true, output: { value: enormous } }); + const result = await callTool(connection, "Gmail.ReadEmail", {}); + expect(result.isError).toBe(false); + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(enormous.length); + expect(result.text.endsWith("[truncated]")).toBe(true); + }); + + test("a result that fits is untouched and reports no truncation", async () => { + answering({ success: true, output: { value: { rows: [1, 2] } } }); + const result = await callTool(connection, "Gmail.ReadEmail", {}); + expect(result.truncated).toBe(false); + expect(result.text).toBe(JSON.stringify({ rows: [1, 2] }, null, 2)); + }); + + test("a refusal is capped too: it carries a vendor-listed action name", async () => { + const enormousName = "Gmail.".padEnd(MAX_RESULT_CHARS + 2_000, "N"); + answering({ success: false, output: { error: { message: "m" } } }); + const result = await callTool(connection, enormousName, {}); + expect(result.isError).toBe(true); + expect(result.truncated).toBe(true); + expect(result.text.length).toBeLessThan(enormousName.length); + expect(result.text.endsWith("[truncated]")).toBe(true); + }); +}); From c0bd4159d310fd7ec6d9cad1f17c009b880357b6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:22:34 +0200 Subject: [PATCH 138/232] fix(arcade): refuse a confirmed return that carries no authorization id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `confirmReturn` read `answer.auth_id` straight out of the vendor's reply and returned it as `{ authorizationId }`. `ConfirmUserResponse.auth_id` is DECLARED required by the SDK's types and VALIDATED by nothing in it, so an answer that omits the field reached the seam as `undefined` wearing the type `string`: a confirmation of the return leg made against no id at all, typed as one. The mint already closes this hole by hand for its own `id` and `url`; the return leg — the second subject claim of this PR, the step that binds the person coming back to the attempt they minted — did not. The id is now checked on the same footing: a non-string or empty `auth_id` THROWS. It throws rather than answering `{ mismatch: true }`, which is the contract routes.ts already states at its binding (`routes.ts:3005-3015`): where the vendor's real shape differs from what was assumed, `confirmReturn` does not quietly answer a clean refusal — the return leg surfaces an outage. An id that could not be read is not evidence the handle belongs to somebody else, and the previous behaviour had the route answer "not your authorization", blaming the person for an answer their vendor failed to make. Nothing is written and the attempt row is left alone on either path, so no stored outcome changes. The adapter's `try` now wraps only the vendor call, so the new refusal cannot be mistaken for a vendor error by the `user_mismatch` normalizer. Same-pattern audit of the whole confirm/return path — every other identifier the return leg binds on that could arrive absent. All already guarded, none changed: `status.userId` (`textOf(...) ?? null`, strict-compared to `attempt.vendorUserId`, so absence reads as mismatch), `status.status` (`?? "unknown"`, never "completed"), `attempt.authId` (null refused at `routes.ts:2999`), the handle (empty refused at `routes.ts:2985`), and the mint's `id`/`url`. Call sites: no symbol added, renamed, resignatured or removed. The only symbol touched is `ConnectedAppBroker.authorizations.confirmReturn`, whose declared signature in `server/src/plugins/broker.ts:446` is unchanged; its single production call site is `server/src/plugins/routes.ts:3019` and it is not changed. Test doubles of it (`server/tests/arcade-grants-route.test.ts:56`, `arcade-authorize-cancel.test.ts:45`, `arcade-authorize-route.test.ts:46`, `arcade-adapter-authorize.test.ts:152-205`) still satisfy the same type. Proved with a real HTTP engine (`server/tests/arcade-confirm-engine.test.ts`, `Bun.serve` + `ARCADE_BASE_URL`), because the existing adapter suite stubs the SDK's `Auth` prototype and so cannot serve an answer with the field missing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 23 +++++- server/tests/arcade-confirm-engine.test.ts | 94 ++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 server/tests/arcade-confirm-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 64cb081c0..ef41090a1 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -903,18 +903,37 @@ export function createArcadeClient(apiKey: string): { // A `user_mismatch` answer is an ANSWER, so it surfaces as `{ mismatch: true }`; any other // failure is an outage or a bug and is rethrown untouched. confirmReturn: async ({ handle, vendorUserId }) => { + let answer: { auth_id?: unknown }; try { - const answer = await client.auth.confirmUser({ + answer = await client.auth.confirmUser({ flow_id: handle, user_id: vendorUserId, }); - return { authorizationId: answer.auth_id }; } catch (error) { if (normalizeArcadeError(error)?.name === "user_mismatch") { return { mismatch: true }; } throw error; } + // THE WHOLE SUBJECT OF THE BINDING, CHECKED HERE AS THE MINT CHECKS ITS OWN — see + // `authorize` above. `ConfirmUserResponse.auth_id` is DECLARED required by the SDK's types + // and is VALIDATED by nothing in it, so an answer that omits the field arrives as + // `undefined` wearing the type `string`, and `{ authorizationId: undefined }` would be a + // confirmation made against nothing that the seam's own type says is an id. + // + // AND IT THROWS RATHER THAN ANSWERING `{ mismatch: true }`, which is the contract routes.ts + // states at its binding: where the vendor's real shape differs from what was assumed, this + // does NOT answer a clean refusal — the return leg surfaces an outage, which is honest, + // because an id that could not be read is not evidence that the handle belongs to somebody + // else. A refusal reading "not your authorization" would blame the person for an answer + // their vendor failed to make. + const authorizationId = answer.auth_id; + if (typeof authorizationId !== "string" || authorizationId === "") { + throw new Error( + "Arcade confirmed a return with no authorization id.", + ); + } + return { authorizationId }; }, }, }; diff --git a/server/tests/arcade-confirm-engine.test.ts b/server/tests/arcade-confirm-engine.test.ts new file mode 100644 index 000000000..d3c3261fe --- /dev/null +++ b/server/tests/arcade-confirm-engine.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; + +/** + * THE RETURN LEG AGAINST A REAL HTTP ENGINE, BECAUSE NOTHING LESS CAN SEE THIS CLASS OF DEFECT. + * + * `arcade-adapter-authorize.test.ts` stubs the SDK's `Auth` PROTOTYPE and hands `confirmUser` a + * hand-written object, so every field that file writes is a field the adapter is guaranteed to + * find. The wire makes no such guarantee: `ConfirmUserResponse.auth_id` is DECLARED required in + * the SDK's types and is not VALIDATED anywhere in it, so a vendor answer that omits the field + * reaches the adapter as `undefined` wearing the type `string` — the same hole the mint closes by + * hand at `arcade-adapter.ts`'s `authorize`. + * + * So these tests serve real JSON over a real socket through `ARCADE_BASE_URL` and let the SDK + * build and parse its own request. `new Arcade({ apiKey })` reads that variable at construction, + * which is why the server is started BEFORE `createArcadeClient`. + */ + +const CONFIRM_PATH = "/v1/auth/confirm_user"; + +type Engine = { + port: number; + /** Every path the SDK asked for, in order. */ + requests: string[]; + stop: () => void; +}; + +/** Serve `body` (verbatim JSON) from the confirm endpoint. */ +function startEngine(body: unknown): Engine { + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(url.pathname); + if (url.pathname === CONFIRM_PATH) return Response.json(body); + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, requests, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(body: unknown): Engine { + running = startEngine(body); + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +const confirm = () => { + const { broker } = createArcadeClient("never-dialled"); + const flow = broker.authorizations; + if (!flow) throw new Error("the Arcade broker runs an authorization flow."); + return flow.confirmReturn({ handle: "fl_1", vendorUserId: "vendor-u1" }); +}; + +describe("confirmReturn answers an authorization id or it answers nothing", () => { + test("a confirmation that OMITS auth_id throws rather than answering an id-less match", async () => { + const e = engine({ next_uri: "https://cloud.arcade.dev/done" }); + + await expect(confirm()).rejects.toThrow(/no authorization id/); + // The call was really made: this is the vendor's answer being refused, not a short circuit. + expect(e.requests).toEqual([CONFIRM_PATH]); + }); + + test("an EMPTY auth_id is refused on the same footing as an absent one", async () => { + engine({ auth_id: "" }); + + await expect(confirm()).rejects.toThrow(/no authorization id/); + }); + + test("a non-string auth_id is refused rather than carried into the binding", async () => { + engine({ auth_id: 7 }); + + await expect(confirm()).rejects.toThrow(/no authorization id/); + }); + + test("the id the vendor really answered rides out untouched", async () => { + engine({ auth_id: "ar_7", next_uri: "https://cloud.arcade.dev/done" }); + + expect(await confirm()).toEqual({ authorizationId: "ar_7" }); + }); +}); From 2d89e249cb8eeccdb46e2911409b99eef1ba7129 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:23:15 +0200 Subject: [PATCH 139/232] fix(app): make the consent resume leg actually leave the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person part-way through connecting an app is sent to /sign carrying the brokered flow's handle. Signed back in, `beforeLoad` threw `redirect({ href: verifierPathFor(flow_id) })` — a relative href with no `reloadDocument`, so TanStack Router handled it INSIDE the app: it parsed the href against the route tree, matched nothing (there is no route for `/api/…`, and there cannot be — the verifier is served by the server), and `__root.tsx`'s `notFoundComponent` sent the unknown address home. Observed, not inferred: the browser ended at "/" and the verifier was never requested. Nothing errored. The authorization the person started was left behind with no indication it had been abandoned — the return leg this PR binds to the attempt they minted never arrived at all. `reloadDocument: true` makes it a document navigation, which is the only kind that leaves the single-page app and therefore the only kind that reaches an API path. Router-core 1.171.27 confirms the mechanism directly: `navigate()` only assigns `window.location` when `reloadDocument` is set or the href parses as a full URL (src/router.ts:2268-2331), and `load-client.ts:1073` is the branch that keeps a `reloadDocument` redirect out of `buildLocation`. Same-pattern audit of the whole of sign.tsx — every navigation or redirect built relatively, or conditioned so it can silently not happen: - `redirect({ to: "/" })` (the no-handle branch): "/" is a real route, so an in-app navigation is what is wanted. Unchanged. - The ternary's two branches both navigate, and `verifierPathFor` returning null is the condition selecting the second — no silent third outcome. - `resumeUrlFor(window.location.origin, flowId)` on both sign-in starters: an ABSOLUTE URL handed to Better Auth as the callback, so it is already a document navigation to the same verifier. Unchanged. That is every one; the beforeLoad redirect was the only instance. RED-GREEN: the new test drives the real router (root route carrying the same notFoundComponent as `__root.tsx`, memory history at /sign?flow_id=h1, a signed-in user) and asserts WHERE THE BROWSER ENDS UP — the document it was sent to, or the in-app location when it was never sent anywhere. Against the unfixed code it fails with `Expected: "/api/plugins/arcade/verify?flow_id=h1" Received: "/"`, reproducing the observed outcome. The pre-existing tests assert only the shape of the thrown redirect object and stayed green throughout, which is why asserting on the destination was the point. Call sites: no symbol added, renamed, resignatured or removed. The only signature touched is the options object passed to TanStack Router's `redirect` at app/src/routes/sign.tsx:51, which has one call site (that line). Deliberately not changed: the broad catch that mislabels non-Error throws, and the `opening` flag cleared only in the catch. Neither is the mechanism here — the defect is entirely in `beforeLoad`, which runs before the component mounts and touches neither. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- app/src/routes/sign.tsx | 17 +++++-- app/tests/sign-resume.test.tsx | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/app/src/routes/sign.tsx b/app/src/routes/sign.tsx index 5202836e9..4d0dba571 100644 --- a/app/src/routes/sign.tsx +++ b/app/src/routes/sign.tsx @@ -44,11 +44,22 @@ export const Route = createFileRoute("/sign")({ ); if (user) { /* - * Signed in already, so the handle has nowhere to be lost between here and the verifier. `href` - * rather than `to` because the verifier is an API path, not a router route. + * Signed in already, so the handle has nowhere to be lost between here and the verifier. + * + * `href` rather than `to` because the verifier is an API path, not a router route — and + * `reloadDocument` because of what follows from that. A redirect the router is left to + * handle is a navigation INSIDE the app: it resolves the href against the route tree, finds + * nothing (there is no route for `/api/…`, and there cannot be — the verifier is served by + * the server, not this build), and `__root.tsx` sends an address it does not know home. The + * person who was part-way through connecting an app would be dropped on the home screen with + * the authorization they started abandoned and nothing said about it, because no request for + * the verifier was ever made. `reloadDocument` makes this a document navigation, which is + * the only kind that leaves the app and therefore the only kind that arrives. */ const verifier = verifierPathFor(search.flow_id); - throw verifier ? redirect({ href: verifier }) : redirect({ to: "/" }); + throw verifier + ? redirect({ href: verifier, reloadDocument: true }) + : redirect({ to: "/" }); } // Loaded here so the screen paints with its buttons rather than painting empty and then // growing them, which reads as "no providers" for exactly as long as the request takes. diff --git a/app/tests/sign-resume.test.tsx b/app/tests/sign-resume.test.tsx index da311f8ba..75e469668 100644 --- a/app/tests/sign-resume.test.tsx +++ b/app/tests/sign-resume.test.tsx @@ -4,7 +4,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createMemoryHistory, createRootRoute, + createRoute, createRouter, + Navigate, + Outlet, RouterProvider, } from "@tanstack/react-router"; import { cleanup, render, waitFor } from "@testing-library/react"; @@ -138,3 +141,87 @@ test("the sign-in starter gets the resume URL as its callbackURL", async () => { `${window.location.origin}/api/plugins/arcade/verify?flow_id=h1`, ); }); + +/* + * THE RETURN LEG HAS TO ACTUALLY ARRIVE. + * + * The verifier is an API path, not a route in this app's tree. A redirect the router is left to + * handle inside the app therefore matches nothing, and `__root.tsx`'s `notFoundComponent` sends an + * unknown address home — so somebody who was asked to sign in half-way through connecting an app + * signs in and lands on the home screen, with the authorization they started left behind and + * nothing said about it. Only a document navigation reaches the verifier at all, which is why this + * asserts on WHERE THE BROWSER ENDS UP rather than on the shape of the thrown redirect. + */ +test("a signed-in arrival with a handle lands on the verifier, not back on the home screen", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(authKeys.currentUser(), { id: "u1" }); + queryClient.setQueryData(authKeys.providers(), { + providers: ["google"], + sso: false, + ssoRequired: false, + }); + + // Leaving the single-page app is an assignment to `window.location`; record it rather than ask + // happy-dom to fetch a path no server is listening on in this test. + const left: string[] = []; + const realLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { + origin: realLocation.origin, + get href() { + return left.at(-1) ?? `${realLocation.origin}/sign?flow_id=h1`; + }, + set href(url: string) { + left.push(url); + }, + replace: (url: string) => left.push(url), + assign: (url: string) => left.push(url), + }, + }); + + const root = createRootRoute({ + component: () => , + // Mirrors app/src/routes/__root.tsx: an address this build does not know goes home. + notFoundComponent: () => , + }); + const home = createRoute({ + getParentRoute: () => root, + path: "/", + component: () =>

home

, + }); + const sign = Route.update({ + id: "/sign", + path: "/sign", + getParentRoute: () => root, + } as never); + // The route tree is assembled by hand, so its types cannot describe it. + const router = createRouter({ + routeTree: root.addChildren([home, sign]), + context: { queryClient }, + history: createMemoryHistory({ initialEntries: ["/sign?flow_id=h1"] }), + } as never) as never; + + try { + render( + + + , + ); + // Where the browser is: the document it was sent to, or the in-app location when it was never + // sent anywhere. + const destination = () => + left.at(-1) ?? + (router as unknown as { state: { location: { href: string } } }).state + .location.href; + await waitFor(() => expect(destination()).not.toContain("/sign")); + expect(destination()).toBe("/api/plugins/arcade/verify?flow_id=h1"); + // And it got there by leaving the app, which is the only way the verifier is ever requested. + expect(left).toEqual(["/api/plugins/arcade/verify?flow_id=h1"]); + } finally { + Object.defineProperty(window, "location", { + configurable: true, + value: realLocation, + }); + } +}); From f65c338a5077a2d7e5930d00430c41ce506de646 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:23:20 +0200 Subject: [PATCH 140/232] fix(arcade): read the status Arcade answered the mint with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `broker.authorize` read the `url` and the `id` off `tools.authorize`'s answer and dropped its `status`, so every answer was treated as "go to this consent screen". Arcade answers `completed` for an account that already holds the grant and `failed` for one it could not begin, and it may answer either WITH a url beside it — so the existing absent-url refusal never saw them. Both were handed back as a live redirect: somebody already connected was sent round a screen they had finished, and a failed authorization was dressed up as a working one, with an `ar_...` recorded against an attempt that can never complete and a fifteen-minute lockout behind it. The mint now reads `status` before it reads `url`, through an ALLOWLIST of the spellings under which consent genuinely is the next step (`not_started`, `pending`) rather than a denylist of the dead ones — the house reading C4 established for `connection_status`. The SDK's four-way union describes what Arcade sends today, not what deserializes: a fifth spelling type-checks as nothing and a denylist missing it would go on sending a browser to it. An ABSENT or blank status stays permissive, as it is for `connection_status`: the field is optional on the wire, and where the vendor said nothing the url and the id are the whole of the evidence. The refusal is a `BrokerRefusalError`, which is the difference between being told and being locked out: the mint route reads an authored refusal as DEFINITE, discards the attempt and shows the sentence, where a bare `Error` normalizes to `null`, is read as "the mint may be live" and leaves the attempt standing for its TTL. Nothing is live in either case — the vendor answered. Same-pattern audit of the whole file, every other vendor answer whose status or outcome field could be left unread: `admin.userConnections.list` rows read `connection_status` (`connectionIsLive`); the tool listing reads `requirements.secrets[].met`, `token_status` and `status` (`grantStateOf`); `auth.status` reads `status` by construction; `tools.execute`'s `success` and `output.error` are read by its one consumer, the transport (`server/src/plugins/arcade.ts:338`), and the envelope is deliberately passed through unread here; `auth.confirmUser`'s `ConfirmUserResponse` carries no status or outcome field at all. `authorize` was the only instance. Call sites: one symbol added, the module-private const `MINT_AWAITS_CONSENT_STATUSES` in `server/src/plugins/arcade-adapter.ts`, read at exactly one site — the `authorize` body in the same file. Nothing renamed, resignatured or removed; `ConnectedAppBroker.authorize`'s signature and return shape are untouched, so `server/src/plugins/routes.ts` (the Composio connect route at :1998 and the Arcade mint route at :2806) and `server/src/plugins/composio-adapter.ts` are unchanged. RED-GREEN: `server/tests/arcade-authorize-status-engine.test.ts`, new, built on the real-HTTP-engine pattern of `arcade-pagination-engine.test.ts` because the prototype-stubbing adapter suite cannot serve a live `{"status":"completed"}` answer. Against the unfixed adapter: 2 pass, 4 fail, each "Expected promise that rejects / Received promise that resolved". Fixed: 6 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 58 ++++++++- .../arcade-authorize-status-engine.test.ts | 122 ++++++++++++++++++ 2 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 server/tests/arcade-authorize-status-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 64cb081c0..d22500f0b 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1,10 +1,10 @@ import Arcade, { APIError } from "@arcadeai/arcadejs"; -import { classify, type ArcadeActions, type ArcadeFailure } from "./arcade"; +import { type ArcadeActions, type ArcadeFailure, classify } from "./arcade"; import { - BrokerRefusalError, - flagOf, type BrokerApp, + BrokerRefusalError, type ConnectedAppBroker, + flagOf, type GrantState, } from "./broker"; @@ -477,6 +477,25 @@ async function connectionsFor( */ const LIVE_CONNECTION_STATUS = "active"; +/** + * The `status` spellings a FRESH MINT may answer under which sending somebody to the consent screen + * is the right next step: the flow has not begun (`not_started`) or is waiting for them (`pending`). + * + * AN ALLOWLIST, AND NOT THE SDK'S UNION TURNED AROUND, for {@link LIVE_CONNECTION_STATUS}'s reason. + * `AuthorizationResponse.status` is declared as a four-way union in the generated types, but the + * union is a description of what Arcade sends today, not a constraint on what arrives: a spelling + * outside it deserializes with no complaint from anybody. The two outcomes that are NOT a consent + * screen — `completed` and `failed` — are therefore refused by absence rather than by name, so that + * a fifth spelling is refused with them instead of being handed to a browser as a live redirect. + * + * SEPARATE FROM {@link grantStateOf}'S READING, deliberately. That one reads the LISTING's + * `requirements.authorization`, whose vocabulary is `token_status` plus an `active`/`inactive` + * status, and whose question is "what does this account already hold". This one reads a mint's own + * answer, whose question is "is a consent screen what happens next". Sharing a table between them + * would make one vendor's change to either vocabulary move the other answer too. + */ +const MINT_AWAITS_CONSENT_STATUSES = new Set(["not_started", "pending"]); + /** * Whether a connection row is one somebody can actually USE, read off the row's OWN `connection_status`. * @@ -811,6 +830,39 @@ export function createArcadeClient(apiKey: string): { tool_name: action, user_id: account.vendorUserId, }); + // WHAT THE VENDOR SAID ABOUT THIS AUTHORIZATION, READ BEFORE ITS URL IS READ AS A NEXT STEP. + // `tools.authorize` does not only answer "go to this screen": it answers `completed` for an + // account that already holds the grant, `failed` for one the vendor could not begin — and it + // may answer either WITH a `url` beside it, so the absent-url refusal below is not this guard + // in disguise. Unread, both were handed back as a live consent link: somebody already + // connected is sent round a screen they finished, and a failed authorization is dressed up + // as a working one, with an `ar_…` recorded against the attempt that can never complete. + // + // AN ALLOWLIST OF THE SPELLINGS UNDER WHICH CONSENT IS GENUINELY THE NEXT STEP, for the + // reason {@link LIVE_CONNECTION_STATUS} gives: the dead vocabulary cannot be enumerated from + // here. The SDK's declared union is a convenience, not a promise about the wire — an engine + // answering `revoked` or `expired` type-checks as nothing and a denylist missing that + // spelling goes on sending a browser to it, which is the defect itself. + // + // SILENCE IS NOT A NO, as it is not in {@link connectionIsLive}: `status` is OPTIONAL on the + // wire, so an absent or blank field leaves the url and the id as the whole of the evidence + // and they stand. FOLDED AND TRIMMED for this file's standing reason: a case the vendor + // changes must not change what somebody is told about their own account. + // + // A `BrokerRefusalError` AND NOT A BARE `Error`, which is the difference between being told + // and being locked out. The mint route keys its three outcomes on what was thrown: an + // authored refusal is DEFINITE, so the attempt is discarded and the actor may press Authorize + // again at once, with this sentence shown; a bare `Error` normalizes to `null` and is read as + // "the mint may be live", leaving the attempt standing for its fifteen-minute TTL. Nothing + // IS live here — the vendor answered, and said this is not a consent to walk through. + const status = textOf(answer.status)?.trim().toLowerCase(); + if (status !== undefined && !MINT_AWAITS_CONSENT_STATUSES.has(status)) { + throw new BrokerRefusalError( + status === "completed" + ? `Arcade answered that this account's authorization for ${action} is already completed, so nobody was sent to a consent screen. Reload this page: the grant it is reporting is already in place.` + : `Arcade answered that this account's authorization for ${action} is "${status}" rather than one waiting for consent, so nobody was sent anywhere and nothing was connected. Press Authorize again to start a fresh one.`, + ); + } // The url is a bearer capability: it goes back to the caller and nowhere else. const url = answer.url; if (typeof url !== "string" || url === "") { diff --git a/server/tests/arcade-authorize-status-engine.test.ts b/server/tests/arcade-authorize-status-engine.test.ts new file mode 100644 index 000000000..3d8a0039c --- /dev/null +++ b/server/tests/arcade-authorize-status-engine.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import { BrokerRefusalError } from "../src/plugins/broker"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** + * WHAT THE VENDOR SAID ABOUT THE AUTHORIZATION IT JUST MINTED, READ OFF A REAL HTTP ANSWER. + * + * The rest of the authorize suite replaces `Tools.prototype.authorize` with a function returning a + * hand-built object. That is blind to this class: the stub returns whatever shape the test chose, + * so it cannot show what happens when the LIVE engine answers `{"status":"completed"}` — the answer + * Arcade gives when the account already holds this grant — or `{"status":"failed"}`. This file + * serves those answers as JSON over a socket through `ARCADE_BASE_URL`, exactly as + * `arcade-pagination-engine.test.ts` does, and `new Arcade({ apiKey })` reads that variable at + * construction, which is why the engine starts BEFORE `createArcadeClient`. + */ + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +type EngineOptions = { + /** The `status` the mint answers with; `null` OMITS the field, as the wire may. */ + status: string | null; + /** Whether the mint answers a consent url beside it. */ + url?: string | null; +}; + +type Engine = { port: number; stop: () => void }; + +function startEngine(options: EngineOptions): Engine { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/v1/tools/authorize") { + const body: Record = { id: "ar_mint1" }; + if (options.status !== null) body.status = options.status; + const consent = + options.url === undefined + ? "https://consent.example/go" + : options.url; + if (consent !== null) body.url = consent; + return Response.json(body); + } + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(options: EngineOptions): Engine { + running = startEngine(options); + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +const mint = () => + createArcadeClient("never-dialled").broker.authorize({ + account, + toolkit: "Gmail", + action: "Gmail.SendEmail", + returnUrl: "https://openbot.example/settings/plugins", + }); + +describe("the mint reads the status Arcade answered with", () => { + test("a pending mint is handed on, which is the whole of the happy path", async () => { + engine({ status: "pending" }); + expect(await mint()).toEqual({ + redirectUrl: "https://consent.example/go", + authorizationId: "ar_mint1", + }); + }); + + test("an answer with NO status is handed on: silence is not a no", async () => { + engine({ status: null }); + expect(await mint()).toEqual({ + redirectUrl: "https://consent.example/go", + authorizationId: "ar_mint1", + }); + }); + + test("a mint the vendor calls completed does not send anybody to a consent screen", async () => { + // Arcade answers this for an account that already holds the grant, and it answers it WITH a + // url: the defect is not an absent url, so the url guard below cannot stand in for this. + engine({ status: "completed" }); + await expect(mint()).rejects.toBeInstanceOf(BrokerRefusalError); + await expect(mint()).rejects.toThrow(/already/i); + }); + + test("a mint the vendor calls failed is not handed on as a live redirect", async () => { + engine({ status: "failed" }); + await expect(mint()).rejects.toBeInstanceOf(BrokerRefusalError); + await expect(mint()).rejects.toThrow(/failed/); + }); + + test("a status this deployment does not know is not read as consent-is-next", async () => { + // The spelling is not in the SDK's declared union at all. An allowlist refuses it; a denylist + // of the dead spellings would hand its url to a browser. + engine({ status: "revoked" }); + await expect(mint()).rejects.toBeInstanceOf(BrokerRefusalError); + }); + + test("the status is read case- and space-insensitively, as every other comparison here is", async () => { + engine({ status: " Completed " }); + await expect(mint()).rejects.toBeInstanceOf(BrokerRefusalError); + }); +}); From 1cc6c6cd22fbffc15c3c459421a20c6301969ca1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:25:32 +0200 Subject: [PATCH 141/232] fix(plugins): offboarding must not file an ended sibling as never asked about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `retireConnectionsFor` asked the broker about every app a departing person held and passed each answer straight onto the trail. On Arcade a connection is keyed `(identity, auth provider)`, so the FIRST app's revoke ends the connection and every app sharing it; the ask that follows for a sibling finds nothing left and answers `false`. Written through, that is `vendorRevocationRequested: false` about a grant this deployment asked for and got — the one reading of that field that must never be written, on the trail an operator reads to check an offboarding completed. Which app got the truthful row and which got the false one was decided by the order the rows came back in. The siblings are now resolved through `resolveSiblings` — the one place that answers which apps a revocation ends — BEFORE any revoke goes out, against the same `account` the revokes are asked with, and memoized across the whole offboarding so the bound is the number of distinct toolkits rather than that number squared. An app whose own ask answered `false` but which a sibling's answered-true revoke ended is recorded as asked about, naming the app it went with (`endedWith`) and how many apps on the identity could not be ruled siblings either way (`unansweredSiblings`, beside `endedWith` and nowhere else, as `removeServer` files it). Nothing is claimed on the strength of a revoke that answered `false`: that is `removeServer`'s rule, kept. On Composio every sibling set is empty by construction and no payload changes. Call sites: no symbol is added, renamed, resignatured or removed. The existing exported `retireConnectionsFor(userId, by)` keeps its signature and return shape `{ retired }`; its callers — `server/src/index.ts:516` (the person-retirement seam), `server/tests/plugin-store.integration.test.ts`, `server/tests/plugin-user-credential.integration.test.ts`, `server/tests/composio-connections.test.ts` — are untouched. The internal `resolveSiblings(input, memo?)` gains a third caller inside `store.ts` (`disconnectBrokered`, `removeServer`, and now `retireConnectionsFor`) with no change to it. RED-GREEN: `server/tests/arcade-sibling-collateral.test.ts` ends a person's grants through an Arcade-shaped broker (one connection per identity and auth provider) and reads what the rows afterwards say. Unfixed it reported the sibling as `vendorRevocationRequested: false` with no `endedWith`; fixed, all 7 tests in that file pass, with `plugin-store.integration.test.ts`, `plugin-user-credential.integration.test.ts`, `arcade-siblings.test.ts`, `arcade-disconnect-cascade.test.ts` and `composio-connections.test.ts` — 234 tests — green and `bun run typecheck` clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/store.ts | 107 +++++++++++++++++- .../tests/arcade-sibling-collateral.test.ts | 67 +++++++++++ server/tests/plugin-store.integration.test.ts | 3 +- 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 28ebf1f4f..5a684e793 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -8315,6 +8315,52 @@ export function createPluginStore(options: PluginStoreOptions) { ) .orderBy(asc(brokeredConnections.app)); + /* + * THE APPS EACH OF THIS PERSON'S REVOCATIONS ENDS BESIDES THE ONE ASKED ABOUT, read BEFORE a + * single revoke goes out and through {@link resolveSiblings}, the one place that answers it. + * + * WHY THIS LOOP NEEDS IT AT ALL, when it already asks about every app the person holds. + * Where a connection is keyed `(identity, auth provider)` — Arcade — the FIRST app's revoke + * ends the connection and every app sharing it, so the ask for a sibling that follows finds + * nothing left and answers `false`. Passed through, that is `vendorRevocationRequested: + * false` on the trail for a grant this deployment asked for and got, which is the one + * reading of that field that must never be written: it says "no vendor was asked" about an + * account that was withdrawn, to whoever later checks that an offboarding completed. The + * order the rows come back in decides which app gets the truthful row and which gets the + * false one, which is to say nothing decides it. + * + * Where each app is its own auth provider — Composio — every set here is empty by + * construction, each ask stands alone, and nothing below differs from what this method + * always did. + * + * RESOLVED AGAINST THE PERSON'S OWN IDENTITY, the same `account` the revokes below are asked + * with. MEMOIZED ACROSS THE WHOLE OFFBOARDING for the reason {@link removeServer} gives: + * `authProviderOf` is a live vendor call on Arcade, and the bound is then the number of + * DISTINCT TOOLKITS this person holds rather than that number squared. Empty with no broker + * configured, where nothing is asked of anybody and the `false` below is the whole truth. + */ + const account: AccountRef = { + holder: "person", + userId, + vendorUserId: userId, + }; + const authProviders = new Map>(); + const endedBesides = new Map< + string, + { siblings: string[]; unanswered: number } + >(); + if (broker) { + for (const connection of brokered) { + endedBesides.set( + connection.toolkit, + await resolveSiblings( + { toolkit: connection.toolkit, account }, + authProviders, + ), + ); + } + } + /* * What the broker was actually asked for each app, kept so the trail below records the answer * rather than the call. False where there is no broker at all: a deployment whose key has @@ -8345,10 +8391,13 @@ export function createPluginStore(options: PluginStoreOptions) { /* * THE PERSON'S OWN ACCOUNT, NAMED AS ONE. Offboarding somebody ends what they * held and never the deployment's shared account, whoever connected it — so the - * holder is fixed here rather than read off anything, and the vendor id is the - * person's own, which is what a personal account is filed under at Composio. + * holder is fixed above rather than read off anything, and the vendor id is the + * person's own, which is what a personal account is filed under at Composio. The + * SAME value the siblings were resolved against, because an answer about one + * identity describing a revocation on another is the whole of what that set is + * for. */ - account: { holder: "person", userId, vendorUserId: userId }, + account, toolkit: connection.toolkit, }) : false, @@ -8388,8 +8437,39 @@ export function createPluginStore(options: PluginStoreOptions) { ); } + /* + * WHICH OF THIS PERSON'S APPS WERE ENDED BY ANOTHER APP'S REVOCATION, and by which one. + * + * Only from a revoke that ANSWERED TRUE: `false` means nothing was found to withdraw or the + * auth provider could not be resolved, and the vendor was not shown to have ended anything, + * so nothing is claimed on the strength of it — the rule {@link removeServer} files its + * siblings by. The asked app is skipped in favour of its own answer, and the first ender + * wins, which is deterministic because the rows are walked in app order. + * + * APPS THIS PERSON HOLDS ARE ALL THIS CAN SPEAK FOR. A sibling resolved on the same identity + * but held by the deployment, or an app whose own revoke threw and so has no row here, is + * left alone: this map only corrects what the loop below is already about to write. + */ + const endedWith = new Map(); + for (const entry of withdrawn) { + if (!entry.requested) continue; + for (const app of endedBesides.get(entry.toolkit)?.siblings ?? []) { + if (app === entry.toolkit) continue; + if (!endedWith.has(app)) endedWith.set(app, entry.toolkit); + } + } + for (const connection of withdrawn) { retired += 1; + /* + * The app whose revocation ended this one, where this one's own ask came back empty + * because that revocation had already taken it. Absent for an app that was ended by its + * own ask, which needs no other grant named beside it. + */ + const endedBy = connection.requested + ? undefined + : endedWith.get(connection.toolkit); + const resolved = endedBesides.get(connection.toolkit); await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", @@ -8410,8 +8490,27 @@ export function createPluginStore(options: PluginStoreOptions) { * withdrawal asked for, or there was nothing to ask about, or there was no broker to * ask. The value of the field is exactly that a reader can tell those apart, so a * constant here would be worse than none. + * + * OR WHAT WAS ASKED ABOUT THE CONNECTION THIS APP SHARES, which is the same claim + * made about the same grant. On Arcade the ask that ended this app may have been + * spelled with a sibling's name, and `revoke`'s `false` here then means "already + * gone", not "nobody asked" — `endedWith` beside it names the app that asked. + */ + vendorRevocationRequested: connection.requested || Boolean(endedBy), + /* + * AND WHERE A SIBLING RELATIONSHIP IS BEING CLAIMED, HOW MANY apps on this identity + * could not be asked about and so could not be ruled siblings either way — an + * integer, never their names; see {@link resolveSiblings}. Beside `endedWith` and + * nowhere else, which is where {@link removeServer} puts it and for its reason: the + * count qualifies a claim about which apps went together, and a row making no such + * claim would be carrying a number about nothing. */ - vendorRevocationRequested: connection.requested, + ...(endedBy + ? { + endedWith: endedBy, + unansweredSiblings: resolved?.unanswered ?? 0, + } + : {}), }, }); } diff --git a/server/tests/arcade-sibling-collateral.test.ts b/server/tests/arcade-sibling-collateral.test.ts index 732ed2db7..569d5f54b 100644 --- a/server/tests/arcade-sibling-collateral.test.ts +++ b/server/tests/arcade-sibling-collateral.test.ts @@ -336,3 +336,70 @@ describe("removing an app", () => { expect(await trailFor([t.gmail, t.calendar])).toEqual([]); }); }); + +describe("offboarding a person", () => { + /* + * CRITERION. Every app the vendor ended as part of this offboarding is recorded as one the + * vendor was asked about, and names the grant it went with. + * + * THE BROKER HERE IS ARCADE'S OWN SHAPE: one connection per (identity, auth provider). The + * first revoke on a provider ends it and answers true; a second, for a sibling app on the same + * connection, answers that there was nothing left to end. Passing that second answer through + * untouched files the sibling as a grant nobody asked about, on the one trail that is supposed + * to say whether the person's access was really withdrawn. + */ + test("records a sibling app as revoked, naming the app whose revoke ended it", async () => { + const t = run(); + await hold(t.gmail, t.person); + await hold(t.calendar, t.person); // same identity, same provider: ended with Gmail + await hold(t.slack, t.person); // its own provider: asked and answered on its own + + const ended = new Set(); + const arcade: ConnectedAppBroker = { + ...broker, + revoke: async ({ account, toolkit }) => { + const connection = `${account.vendorUserId}:${providerOf(toolkit)}`; + if (ended.has(connection)) return false; + ended.add(connection); + return true; + }, + }; + const arcadeStore = createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker: arcade, + deploymentId: "acme", + sharedUse: async () => ({ allowed: true }), + }); + + expect(await arcadeStore.retireConnectionsFor(t.person, "admin")).toEqual({ + retired: 3, + }); + expect(await rowsFor([t.gmail, t.calendar, t.slack])).toEqual([]); + + const trail = await trailFor([t.gmail, t.calendar, t.slack]); + const row = (app: string) => trail.find((event) => event.app === app); + // The app whose revoke ended the Google connection, and the one on its own provider: both + // asked directly, neither ended with anything else. + expect(row(t.gmail)?.payload).toMatchObject({ + reason: "person_removed", + vendorRevocationRequested: true, + }); + expect(row(t.gmail)?.payload.endedWith).toBeUndefined(); + expect(row(t.slack)?.payload).toMatchObject({ + vendorRevocationRequested: true, + }); + expect(row(t.slack)?.payload.endedWith).toBeUndefined(); + // And the sibling, whose own ask answered false because Gmail's had already ended it. + expect(row(t.calendar)?.payload).toMatchObject({ + reason: "person_removed", + vendorRevocationRequested: true, + endedWith: t.gmail, + unansweredSiblings: 0, + }); + }); +}); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 45ec322d1..6ccae1fc0 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6795,7 +6795,8 @@ function brokerSpy(answers: { broker: { // This stub stands in for Composio, whose grant is held locally; see `ConnectedAppBroker.grantIsLocal`. grantIsLocal: true, - // Nothing here asks what auth provider a connection is held under. + // A broker that does not say what auth provider a connection is held under, which is what + // the sibling resolution has to treat as "we do not know" rather than as "none". authProviderOf: async () => null, // Nothing here reads either member; both are declared because the seam requires a // broker to SAY what it runs — see `ConnectedAppBroker.authorizations`. From fcb7adde37ca37804a0ed6efaa061b073b9ace8d Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:25:45 +0200 Subject: [PATCH 142/232] fix(arcade): an empty vendor structure is not a vendor answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classify({ behavior: {} })` answered `{ write, destructive: false }` while `classify({})` and `classify({ behavior: null })` both answer the closed `{ write, destructive: true }` two lines above it. An EMPTY container Arcade opened and put nothing in was therefore worth strictly more than NO container: the absence of a stated restriction read as the absence of a restriction, and `isClassified` counted it, so an action Arcade has not classified reported as classified and the coverage warning that exists to name unannotated actions could not fire for it. A behaviour block stating NEITHER `destructive` NOR `read_only` now answers the closed verdict, as an absent block does. The per-flag `flagOf(x, false)` defaults are untouched once EITHER flag is stated — `{ read_only: true }` and `{ destructive: true }` are the vendor describing the action, and those readings do not move. This is deliberately the OPPOSITE call from `connectionIsLive`, where an absent `connection_status` reads as live on purpose, and the difference is what the silence costs in each direction. There the field is optional on the wire and the pessimistic reading un-connects every real connection against any engine that omits it, with `revoke` answering false without spending a delete; the cost falls on people who did nothing wrong. Here failing closed costs one approval prompt in front of an action that may turn out to be a read — annoying and correct, as the docblock already said — while failing open costs a write nobody was asked about. And the closed reading is already the status quo for an absent block, so nothing is stranded that was not stranded before. The same pattern, one requirement description along: `requirements.secrets` present but NOT a list fell through `Array.isArray(…) ? … : []` as zero iterations, i.e. "no secrets, none missing", and `grantStateOf` went on to answer `authorized` for an action that may be waiting on an unset `GITHUB_SERVER_URL` — `needs-operator`, the state that exists to name the key, could not fire. Absent `secrets` still means none required (the ordinary case); present-but-not-a-list now answers `unknown` with a reason. Tested against a REAL HTTP ENGINE, as `arcade-pagination-engine.test.ts` and `arcade-malformed-listing-engine.test.ts` are: the rest of the adapter suite stubs the SDK's resource prototypes and decides for itself what the adapter is handed, so it is blind to anything that happens between the wire and the reader. Call sites: no exported symbol added, renamed, resignatured or removed. The only new name is `stated`, a local arrow function inside `classify`. The two readers whose ANSWERS change, both audited, both wanting the new answer: - `classify` — `server/src/plugins/arcade.ts:236` (`listTools`, writes `effect`/`destructive` onto the `mcp_tools` row) and `server/src/plugins/arcade-adapter.ts:204` (`isClassified`, feeds `BrokerApp.classifiedCount` and the admin coverage warning). No other caller in `server/src` or `app/src`. - `grantStateOf` — one caller, `grantsFor` in the same file, reached through `ConnectedAppBroker.grantsFor` and the grants route. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 38 +++- server/src/plugins/arcade.ts | 19 ++ ...ade-empty-vendor-structures-engine.test.ts | 197 ++++++++++++++++++ 3 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 server/tests/arcade-empty-vendor-structures-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 64cb081c0..581d426fd 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1,10 +1,10 @@ import Arcade, { APIError } from "@arcadeai/arcadejs"; -import { classify, type ArcadeActions, type ArcadeFailure } from "./arcade"; +import { type ArcadeActions, type ArcadeFailure, classify } from "./arcade"; import { - BrokerRefusalError, - flagOf, type BrokerApp, + BrokerRefusalError, type ConnectedAppBroker, + flagOf, type GrantState, } from "./broker"; @@ -189,6 +189,13 @@ const CLOSED_VERDICT = { effect: "write", destructive: true } as const; * counted — counted, `AirtableApi`'s `metadata: null` actions would report as classified, and the * coverage warning that exists to name them would never fire. * + * AN EMPTY `behavior` IS NOT A CLASSIFICATION EITHER, and `classify` is where that is decided: a + * block stating neither flag answers the closed verdict, so it arrives here as the fallback it is + * and the `flagOf(behavior.destructive, false)` below — the collision-breaker, not a second reading + * of the wire — answers `false` for it. Were `{}` read as a plain write, every such action would + * report as classified and the coverage warning would miss exactly the actions it exists to name, + * the same way `AirtableApi`'s `metadata: null` actions would. + * * THE CLOSED VERDICT IS ALSO A REAL ANSWER, which is the only subtlety here: `Attio.RemoveFromList` * genuinely publishes `destructive: true` and lands on the same pair. So the verdict is read first, * and only that one collision is broken — by asking whether Arcade actually sent a `behavior` whose @@ -550,7 +557,32 @@ function grantStateOf(item: unknown): GrantState[] { if (!action) return []; const requirements = record(tool?.requirements); + /* + * A SECRETS FIELD THAT IS NOT A LIST IS UNREADABLE, NOT EMPTY — the same judgement `classify` + * makes about an empty `behavior`, one requirement description along. `secrets` absent is the + * ordinary case and genuinely means this action needs no deployment secret; `secrets: {}`, or any + * other non-list, is Arcade describing requirements in a shape this cannot read, and silently + * folding it to "no secrets, none missing" lets the function go on to answer `authorized` for an + * action that may be waiting on an unset `GITHUB_SERVER_URL`. Nobody is then told to set it: the + * `needs-operator` state that exists to name it cannot fire, and the person is left with an + * action the screen says they hold and the vendor refuses. `unknown` with a reason is what the + * docblock's "anything the listing does not say clearly" already promises. + */ const rawSecrets = requirements?.secrets; + if ( + rawSecrets !== undefined && + rawSecrets !== null && + !Array.isArray(rawSecrets) + ) { + return [ + { + action, + state: "unknown", + reason: + "Arcade listed this action's deployment secrets as something other than a list, so whether any is unset could not be read.", + }, + ]; + } const missing: string[] = []; let secretUnreadable = false; for (const raw of Array.isArray(rawSecrets) ? rawSecrets : []) { diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 9f03963de..2417db14a 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -47,6 +47,23 @@ export function toolkitOf(url: string): string | null { * documents a boolean), IS A DESTRUCTIVE WRITE, which is also what a listing that would not say * already records in `./mcp`. It fails safe by putting an approval in front of a read, which is * annoying and correct; the alternative lets a deletion through unannounced. + * + * AN EMPTY BEHAVIOUR OBJECT IS A VENDOR THAT SAID NOTHING, AND IS READ EXACTLY AS AN ABSENT ONE. + * `{}` — or a block carrying only `idempotent` and the like — states NEITHER flag, so the two + * `flagOf(…, false)` defaults below would otherwise manufacture "not destructive, not read-only" + * out of nothing and answer the plain-write verdict: a container Arcade opened and put nothing in, + * read as Arcade declaring the action safe to run unannounced. The absence of a stated restriction + * is not the absence of a restriction, and an EMPTY container cannot be allowed to be worth more + * than NO container two lines above it. + * + * NOT THE SAME CALL AS `arcade-adapter.ts`'s `connectionIsLive`, WHERE SILENCE IS DELIBERATELY + * PERMISSIVE AND SAYS SO AT LENGTH. There + * the silent field is `connection_status`, optional on the wire, and a pessimistic reading + * un-connects every real connection against any engine that omits it — the cost of failing closed + * falls on people who did nothing wrong. Here the cost of failing closed is one approval prompt in + * front of an action that may turn out to be a read; the cost of failing open is a deletion that + * nobody was asked about. The flags' per-field defaults stand once EITHER flag is stated, which is + * the vendor describing this action rather than saying nothing at all. */ export function classify(metadata: unknown): { effect: "read" | "write"; @@ -57,6 +74,8 @@ export function classify(metadata: unknown): { const behavior = (metadata as { behavior?: unknown }).behavior; if (typeof behavior !== "object" || behavior === null) return closed; const flags = behavior as { destructive?: unknown; read_only?: unknown }; + const stated = (value: unknown) => value !== undefined && value !== null; + if (!stated(flags.destructive) && !stated(flags.read_only)) return closed; const destructive = flagOf(flags.destructive, false); const readOnly = flagOf(flags.read_only, false); if (destructive === null || readOnly === null) return closed; diff --git a/server/tests/arcade-empty-vendor-structures-engine.test.ts b/server/tests/arcade-empty-vendor-structures-engine.test.ts new file mode 100644 index 000000000..e62fb093e --- /dev/null +++ b/server/tests/arcade-empty-vendor-structures-engine.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { listTools, useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** + * AN EMPTY VENDOR STRUCTURE IS NOT A VENDOR ANSWER, AGAINST A REAL HTTP ENGINE. + * + * `metadata: { behavior: {} }` and `requirements: { secrets: {} }` are containers Arcade opened and + * put nothing in. Read as answers they say "not destructive" and "nothing is required" — a + * permission decision taken on missing data, and the opposite of what an ABSENT `metadata` or an + * absent `behavior` already answers two lines away in the same function. + * + * SERVED OVER A REAL SOCKET, as `arcade-pagination-engine.test.ts` and + * `arcade-malformed-listing-engine.test.ts` are and for their reason: the rest of the adapter suite + * stubs the SDK's resource PROTOTYPES and so decides for itself what the adapter is handed, which + * cannot see anything that happens between the wire and the reader. Here the SDK parses the body + * and builds its own pages, so what these assertions read is what a deployment would. + */ + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +type Engine = { port: number; stop: () => void }; + +/** The rows `/v1/tools` serves, set per test. One short page ends the listing. */ +let serving: unknown[] = []; + +function startEngine(): Engine { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + const limit = Number(url.searchParams.get("limit") ?? "100"); + const offset = Number(url.searchParams.get("offset") ?? "0"); + if (url.pathname === "/v1/tools") { + const page = serving.slice(offset, offset + limit); + return Response.json({ + items: page, + offset, + limit, + page_count: page.length, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(rows: unknown[]): void { + serving = rows; + running = startEngine(); + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; +} + +afterEach(() => { + running?.stop(); + running = null; + serving = []; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +/** One listed action, with whatever `metadata` and `requirements` the case is about. */ +const toolRow = ( + name: string, + metadata: unknown, + requirements: unknown = { + authorization: { + provider_id: "google", + oauth2: { scopes: [] }, + token_status: "completed", + }, + }, +) => ({ + qualified_name: `Gmail.${name}`, + description: "", + toolkit: { name: "Gmail", description: "" }, + input: { parameters: [] }, + metadata, + requirements, +}); + +const listed = async () => { + const { actions } = createArcadeClient("never-dialled"); + useArcadeClient(actions); + return await listTools({ url: "arcade://Gmail" }); +}; + +describe("an empty behavior object is the vendor saying nothing, not saying safe", () => { + test("a behavior object stating neither flag is read as the closed verdict", async () => { + engine([toolRow("EmptyBehavior", { behavior: {} })]); + + expect(await listed()).toMatchObject([ + { name: "Gmail.EmptyBehavior", effect: "write", destructive: true }, + ]); + }); + + test("a behavior object carrying only unrelated keys says nothing either", async () => { + engine([toolRow("Idempotent", { behavior: { idempotent: true } })]); + + expect(await listed()).toMatchObject([ + { name: "Gmail.Idempotent", effect: "write", destructive: true }, + ]); + }); + + test("an empty behavior does not count towards classifiedCount", async () => { + engine([ + toolRow("EmptyBehavior", { behavior: {} }), + toolRow("Annotated", { + behavior: { read_only: true, destructive: false }, + }), + ]); + const { broker } = createArcadeClient("never-dialled"); + + const apps = await broker.listApps(); + expect(apps.length).toBe(1); + expect(apps[0]?.actionCount).toBe(2); + // ONE of the two: the empty container is an action Arcade has not classified, which is exactly + // what the coverage warning exists to name. + expect(apps[0]?.classifiedCount).toBe(1); + }); + + test("a stated behaviour is still read, in both directions", async () => { + engine([ + toolRow("Read", { behavior: { read_only: true } }), + toolRow("Write", { behavior: { destructive: false, read_only: false } }), + toolRow("Delete", { behavior: { destructive: true } }), + ]); + + expect(await listed()).toMatchObject([ + { name: "Gmail.Read", effect: "read", destructive: false }, + { name: "Gmail.Write", effect: "write", destructive: false }, + { name: "Gmail.Delete", effect: "write", destructive: true }, + ]); + }); +}); + +describe("a secrets requirement that is not a list is unreadable, not satisfied", () => { + const withSecrets = (secrets: unknown) => + toolRow( + "Send", + { behavior: { read_only: true } }, + { + secrets, + authorization: { + provider_id: "google", + oauth2: { scopes: [] }, + token_status: "completed", + }, + }, + ); + + test("an empty secrets object does not answer authorized", async () => { + engine([withSecrets({})]); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.grantsFor({ account, toolkit: "Gmail" })).toEqual([ + { + action: "Gmail.Send", + state: "unknown", + reason: + "Arcade listed this action's deployment secrets as something other than a list, so whether any is unset could not be read.", + }, + ]); + }); + + test("no secrets field at all is still no secrets required", async () => { + engine([toolRow("Send", { behavior: { read_only: true } })]); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.grantsFor({ account, toolkit: "Gmail" })).toEqual([ + { action: "Gmail.Send", state: "authorized", scopes: [] }, + ]); + }); + + test("a listed secret is still read, met or unset", async () => { + engine([withSecrets([{ key: "GITHUB_SERVER_URL", met: false }])]); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.grantsFor({ account, toolkit: "Gmail" })).toEqual([ + { + action: "Gmail.Send", + state: "needs-operator", + missing: ["GITHUB_SERVER_URL"], + }, + ]); + }); +}); From 1fd566141baee9de5c3b4e8ad0b608cd238b7423 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:26:35 +0200 Subject: [PATCH 143/232] fix(arcade): refuse an attempt that cannot say whose account it is for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier rebuilt the attempt's account reference with a ternary whose else-branch swallowed every `(holder, user_id)` pair it did not recognise: attempt.holder === "person" && attempt.userId ? { holder: "person", userId, vendorUserId } : { holder: "deployment", vendorUserId } So a `person` attempt carrying no user id came out as the DEPLOYMENT's account, and D28's withdrawal was then aimed at it — a disconnect somebody asked for ending the installation's connection while their own stayed live, or the installation's ended on one person's behalf. A reference is not a label: it is what a revoke, a grant read and a call are pointed at. The state is UNREACHABLE through `pending_authorizations`: F2's `pending_authorizations_holder_check` refuses that pair at the database, as `server/tests/arcade-attempt-constraints.test.ts` asserts directly, and the only writer (`claimAuthorizationAttempt`) builds the row from a typed `AccountRef`. Unreachable is the reason to REFUSE rather than to coerce: an impossible pair answered with a refusal is a dead end, the same pair absorbed into the other holder is a wrong act with no trace. The read is now `asAccountRef`, which restates the CHECK where TypeScript can see it and answers `null` for every pair the CHECK excludes — including the empty-string user id, which `IS NOT NULL` does not stop. The verifier calls it beside the null-`auth_id` gate, BEFORE the vendor is asked anything, and refuses `no-attempt`: the attempt can never complete, so no vendor call is worth spending on it, nothing is written and the row stands until its TTL. No constraint is weakened and no migration is added. Audited the whole of `routes.ts` for the same pattern. The only other places a holder is attached are :1377-1378, which label rows from `connectionsFor` and `brokeredConnectionsFor` — queries keyed on the actor, and the latter filters `holder = 'person'` in SQL — so the label is correct by construction rather than rebuilt from stored columns. :1527, :1985, :2216 and :2501 read a resolved ref's holder and build nothing. Two sibling coercions live in `store.ts` (`accountOf` at :4161, `accountsOn` at :5061, the second defaulting a null user id to `""`); both are out of this finding's file scope and are left alone, reported rather than touched. Call sites of the one symbol added, `asAccountRef` (`server/src/plugins/shared-accounts.ts:52`): - server/src/plugins/routes.ts:42 (import) - server/src/plugins/routes.ts:3016 (the verifier's attempt read) Nothing was renamed, resignatured or removed. Red against the unfixed route, with the revoke asserted before the refusal code so the failure names the account that was reached: expect(revokeCalls).toEqual([]) - [] + [ { "account": { "holder": "deployment", + "vendorUserId": "verifier-a6da692c" }, + "toolkit": "Gmail-a6da692c-1" } ] Green: `server/tests/arcade-verify.test.ts` 36 pass / 0 fail; `arcade-attempt-constraints`, `shared-accounts`, `arcade-authorize-route`, `arcade-authorize-cancel`, `arcade-attempt-store.integration`, `plugin-routes` 144 pass / 0 fail; `arcade-disconnect-route`, `arcade-disconnect-cascade`, `arcade-grants-route`, `arcade-completion-transaction.integration`, `arcade-journey.integration`, `routes-arcade-urls` 48 pass / 0 fail. `bun run typecheck` exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/routes.ts | 35 +++++++--- server/src/plugins/shared-accounts.ts | 46 +++++++++++++ server/tests/arcade-verify.test.ts | 97 ++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 11 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index d5f8ee5ec..d29599a30 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -36,7 +36,11 @@ import { redirectUriFor, sealConnectState, } from "./oauth"; -import { type AccountRef, asAccountMode } from "./shared-accounts"; +import { + type AccountRef, + asAccountMode, + asAccountRef, +} from "./shared-accounts"; import { asSharedUseApproval, exposureOf, @@ -194,7 +198,8 @@ export const AUTHORIZATION_REFUSALS = [ "not-supported", // The link arrived without the part that says which authorization it finishes. "missing-handle", - // Nothing of this person's is waiting: never started, expired, or another session's. + // Nothing USABLE of this person's is waiting: never started, expired, another session's, a mint + // that never answered with an id, or a row that cannot say whose account it is for. "no-attempt", // The vendor was asked and did not answer. "vendor-unavailable", @@ -2994,10 +2999,22 @@ export function createPluginRoutes( * the fifteen-minute TTL, so all three absences arrive here as `null` and are refused in one * sentence. A NULL `auth_id` is the fourth: its mint never completed, so there is nothing for * the binding below to be checked against and the attempt can only ever expire. + * + * A ROW THAT CANNOT SAY WHOSE ACCOUNT IT IS FOR IS THE FIFTH, AND IT IS REFUSED HERE RATHER + * THAN READ AS THE DEPLOYMENT'S. `(holder, user_id)` is held by + * `pending_authorizations_holder_check`, so a `person` row with no user id cannot be written + * and this branch is unreachable through the table — which is exactly why it must be a + * refusal: the reading it replaces rebuilt that row as `{ holder: "deployment" }` and then + * aimed the withdrawal below at the DEPLOYMENT's account, ending a connection nobody asked + * about while the person's stayed live. Refused BEFORE the vendor is asked anything, like the + * null `auth_id` above and for the same reason: the attempt can never complete, so no call is + * worth spending on it, and nothing is written — the row stands until its TTL. */ const actorId = context.var.actor.id; const attempt = await store.authorizationAttemptFor(actorId); if (!attempt || attempt.authId === null) return refusedWith("no-attempt"); + const attemptAccount = asAccountRef(attempt); + if (!attemptAccount) return refusedWith("no-attempt"); const authId = attempt.authId; /* @@ -3103,14 +3120,12 @@ export function createPluginRoutes( * answers `false` it is kept as `unreconciled` rather than closed, because a grant * we know about and could not end is worth a row somebody can act on. */ - const retired: AccountRef = - attempt.holder === "person" && attempt.userId - ? { - holder: "person", - userId: attempt.userId, - vendorUserId: attempt.vendorUserId, - } - : { holder: "deployment", vendorUserId: attempt.vendorUserId }; + /* + * THE RETIRED IDENTITY IS THE ATTEMPT'S OWN, READ ONCE AT THE TOP AND NOT REBUILT HERE. A + * reference built a second time from the row's columns is a second chance to build it wrong, + * and the one this replaced defaulted a pair it did not recognise to the deployment's. + */ + const retired: AccountRef = attemptAccount; const fresh = await brokeredAccountFor(context, attempt.serverId); if (fresh.refusal || !sameAccount(fresh.account, retired)) { const row = await store.serverAddress(attempt.serverId); diff --git a/server/src/plugins/shared-accounts.ts b/server/src/plugins/shared-accounts.ts index aeaa2562a..f4737a429 100644 --- a/server/src/plugins/shared-accounts.ts +++ b/server/src/plugins/shared-accounts.ts @@ -25,6 +25,52 @@ export function asAccountMode(value: unknown): AccountMode | null { return value === "personal" || value === "shared" ? value : null; } +/** + * A STORED `(holder, user_id)` PAIR READ BACK AS THE ACCOUNT IT NAMES, or `null` when it names no + * account this deployment may act in. + * + * THE POINT IS THE `null`, AND IT EXISTS BECAUSE THE OTHER READING IS SILENT. A row that says + * `person` and carries no user id was for years rebuilt as `{ holder: "deployment" }` by a ternary + * whose else-branch quietly absorbed it — and a reference is not a label: it is what a revoke, a + * grant read and a call are aimed at. Aimed one holder out, a disconnect somebody asked for ends + * the DEPLOYMENT's connection and leaves theirs live, or ends the deployment's on one person's + * behalf. Both failures look exactly like success from the browser that asked. + * + * THE TWO REFUSALS ARE THE DATABASE'S OWN CHECK, RESTATED WHERE TYPESCRIPT CAN SEE IT. + * `brokered_connections_holder_check` and `pending_authorizations_holder_check` both hold + * `(person AND user_id IS NOT NULL) OR (deployment AND user_id IS NULL)`, so for a row that came + * out of either table every refusal here is already impossible — which is the whole reason to + * refuse rather than to coerce. An impossible state answered with a plain `null` is a caller's + * refusal; the same state absorbed into the other holder is a wrong act with no trace. The empty + * string is refused with the null, because a CHECK for `IS NOT NULL` does not stop `''` and `''` is + * nobody's user id. + * + * `holder` IS TAKEN AS `string` ON PURPOSE. drizzle's `$type<>` is erased at runtime, so a column + * declared as the pair can still deliver a third word; a parameter typed as the union would let the + * compiler promise what only the CHECK enforces. + */ +export function asAccountRef(stored: { + holder: string; + userId: string | null; + vendorUserId: string; +}): AccountRef | null { + if (stored.holder === "person") { + return stored.userId + ? { + holder: "person", + userId: stored.userId, + vendorUserId: stored.vendorUserId, + } + : null; + } + if (stored.holder === "deployment") { + return stored.userId + ? null + : { holder: "deployment", vendorUserId: stored.vendorUserId }; + } + return null; +} + export type AccountAnswer = | { kind: "none" } | { kind: "person"; userId: string } diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 1ccf0eac9..57bbfd417 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -158,6 +158,16 @@ function build( attemptReadThrows?: boolean; /** Makes the guard's ROLE read throw, which is a store read outside the handler's own code. */ roleReadThrows?: boolean; + /** + * Hands the route an attempt row whose `(holder, user_id)` pair the TABLE would refuse. + * + * Injected at the store rather than inserted, because `pending_authorizations_holder_check` + * holds that pair in the database and these combinations cannot be written at all — which is + * the point. The route reads the pair out of a row and aims a REVOCATION with it, so what it + * does with a pair it should never see is a fact about the route and not about the table, and + * the only way to ask is to put the row in its hand. + */ + attemptAs?: { holder?: string; userId?: string | null }; } = {}, ) { const handle = options.database ?? database; @@ -199,7 +209,17 @@ function build( throw new Error("injected attempt read failure"); }, } - : pluginStore; + : options.attemptAs + ? { + ...pluginStore, + authorizationAttemptFor: async (actorId: string) => { + const row = await pluginStore.authorizationAttemptFor(actorId); + return row === null + ? null + : ({ ...row, ...options.attemptAs } as typeof row); + }, + } + : pluginStore; args[29] = "brokerBag" in options ? options.brokerBag : { broker, provider: "arcade" }; return createApp(...args); @@ -885,6 +905,81 @@ describe("a consent whose account context has moved on", () => { }); }); +/** + * D4: AN ATTEMPT THAT CANNOT SAY WHOSE ACCOUNT IT IS FOR IS REFUSED, NEVER READ AS THE + * DEPLOYMENT'S. + * + * The holder distinction IS the authorization model: a `person` attempt belongs to one signed-in + * human and a `deployment` attempt to the installation. The route used to rebuild the attempt's + * account with a ternary whose else-branch swallowed every pair it did not recognise — so a + * `person` row carrying no user id came out as `{ holder: "deployment" }` and the D28 withdrawal + * was then AIMED AT THE DEPLOYMENT'S ACCOUNT. A revoke pointed one holder out is a disconnect + * somebody asked for that ends the installation's connection, or the installation's connection + * ended on one person's behalf; from the browser both look exactly like success. + * + * EVERY PAIR HERE IS ONE THE DATABASE ALREADY REFUSES — `pending_authorizations_holder_check` is + * asserted directly in `arcade-attempt-constraints.test.ts` — so these rows reach the route only + * because the store is replaced. That is deliberate and it is the whole argument: the state is + * unreachable through the table, and an unreachable state must therefore be a REFUSAL in the code + * rather than a silent re-reading, because a re-reading is what a corrupt or hand-edited row, a + * future second writer, or a store that is not this one would turn into a wrong act with no trace. + * + * The app is switched to Shared in each case so the run would reach the withdrawal: these tests + * assert the vendor was never asked at all, which is strictly stronger than asserting it was asked + * correctly. + */ +describe("an attempt whose holder and user id do not agree", () => { + async function switchToShared() { + await database + .update(mcpServers) + .set({ accountMode: "shared", sharedVendorUserId: SHARED_IDENTITY }) + .where(eq(mcpServers.id, APP)); + } + + /** The pairs `(holder = 'person' AND user_id IS NOT NULL) OR (deployment AND user_id IS NULL)` excludes. */ + const illegal: [string, { holder?: string; userId?: string | null }][] = [ + ["a person attempt with no user id", { holder: "person", userId: null }], + [ + "a person attempt whose user id is the empty string", + { holder: "person", userId: "" }, + ], + [ + "a deployment attempt carrying a user id", + { holder: "deployment", userId: ACTOR }, + ], + ["an attempt whose holder is not one of the two", { holder: "llama" }], + ]; + + for (const [name, attemptAs] of illegal) { + test(`${name} is refused, and nothing is revoked`, async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + + const response = await verify(ACTOR, "?flow_id=flow-abc", { attemptAs }); + + /* + * THE WHOLE FINDING, AND IT IS ASSERTED FIRST ON PURPOSE. The refusal code below is the + * symptom; what the defect DID was aim a revocation, so a failure here has to name the + * account that was reached rather than a redirect that was wrong. No account was rebuilt, so + * no revocation was aimed anywhere — least of all at the deployment's. + */ + expect(revokeCalls).toEqual([]); + // Nor was a single vendor call spent on a row that can never complete. + expect(confirmCalls).toEqual([]); + expect(statusCalls).toEqual([]); + + await expectRefusal(response, "no-attempt"); + + // AND NOTHING WAS WRITTEN OR CLOSED: the row stands until its TTL, as it does for the other + // attempt that can never complete (a null `auth_id`). + const row = await attemptRow(); + expect(row?.outcome).toBeNull(); + expect(row?.completedAt).toBeNull(); + await expectNothingWritten(); + }); + } +}); + describe("a return leg that completes", () => { test("lands on the app's own page, built from the ATTEMPT ROW", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); From e7ef88f2af50b9719a484abadb829348c1a3dc24 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:26:46 +0200 Subject: [PATCH 144/232] test(db): make the schema guards fail when a CHECK is mutated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A round-1 probe widened the CHECK in `0057_arcade_provider.sql` and every schema guard stayed green. Reproduced on a throwaway database: with `brokered_connections_provider_check` grown a third provider and `pending_authorizations_outcome_check` grown a fifth outcome, all 20 tests in `arcade-provider-check`, `arcade-attempt-constraints`, `arcade-attempt-schema` and `migration-journal` passed. A guard that cannot fail is not a guard. Two blind spots, both now closed. The file-level guard read tables, columns and indexes out of each migration and compared them with the snapshot pair that brackets it, and said nothing about CHECK constraints — which the snapshots record in full, predicate and all. So the migration and its snapshot could disagree about what the database is allowed to hold, and `generate` would diff the next migration against a schema no database was ever in. `migration-journal.test.ts` now reads every CHECK a migration writes, in both spellings `generate` emits (inline in `CREATE TABLE` and `ALTER TABLE ... ADD CONSTRAINT`), and compares predicate text with the snapshots in both directions. A CHECK body cannot be matched with a regular expression — every interesting one ends in `IN ('a', 'b')`, whose closing paren is not the constraint's — so the body is taken by a paren scanner that keeps quoted strings whole. The database-level guards inserted one junk value and read back the name of the constraint that refused it. That is blind to widening in exactly the direction nobody reviews: `arcade` still inserts, `nope` is still refused, and a provider the broker has no adapter for becomes storable. The three rosters this PR cares about are now read back from `pg_get_constraintdef` and pinned exactly. Mutation evidence, per CHECK, on a throwaway database dropped afterwards; each mutation leaves the pre-existing tests in its file green and fails only the new guard, and each is green again once restored: - `brokered_connections_provider_check` + 'retool' -> 3 pass, 1 fail - `pending_authorizations_outcome_check` + 'reconciled' -> 7 pass, 1 fail - `pending_authorizations_holder_check` + 'service' -> 7 pass, 1 fail - renaming `pending_authorizations_holder_check` -> the roster read throws by name rather than reading as an empty roster At the file level, widening the 0057 or 0058 predicate, or deleting an `ADD CONSTRAINT` statement outright, now fails `migration-journal.test.ts` naming the constraint and the direction of the disagreement. No migration, journal entry or snapshot was touched; the finding is about the tests. `0057` and `0058` remain byte-identical to what `generate` wrote. Symbols added, with every call site: - `checkRoster` (server/tests/support/constraints.ts, new file) — called in server/tests/arcade-provider-check.test.ts and server/tests/arcade-attempt-constraints.test.ts. - `balanced`, `normalised`, `checksIn`, `constraintsDropped`, `checksOf`, `checkDisagreements`, type `Check` (all file-local to server/tests/migration-journal.test.ts) — `checkDisagreements` is called from `disagreements` in that file and from the three unit tests beside it; the rest are called only by `checkDisagreements` and those tests. - `Snapshot["tables"][string]` gained an optional `checkConstraints` field, read only by `checksOf`. Nothing was renamed, resignatured or removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- .../tests/arcade-attempt-constraints.test.ts | 22 ++ server/tests/arcade-provider-check.test.ts | 17 ++ server/tests/migration-journal.test.ts | 203 ++++++++++++++++++ server/tests/support/constraints.ts | 43 ++++ 4 files changed, 285 insertions(+) create mode 100644 server/tests/support/constraints.ts diff --git a/server/tests/arcade-attempt-constraints.test.ts b/server/tests/arcade-attempt-constraints.test.ts index 92b86cd66..69496f921 100644 --- a/server/tests/arcade-attempt-constraints.test.ts +++ b/server/tests/arcade-attempt-constraints.test.ts @@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; import { createDatabase } from "../src/db/client"; import { mcpServers, pendingAuthorizations } from "../src/db/schema/plugins"; +import { checkRoster } from "./support/constraints"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -107,6 +108,27 @@ describe("pending_authorizations holds its declared unions", () => { ).toBe("pending_authorizations_holder_check"); }); + /** + * The rosters themselves, because every test above asks only about one value outside them. + * + * `llama` and `banana` go on being refused by a constraint that has quietly grown a member, and + * `cancelled` goes on being refused right up until the day it is the member that was added — at + * which point that test fails for the right reason and these two say which roster moved. The + * TypeScript unions in `schema/plugins` are erased at runtime, so these lines and the migration + * are the only two places the rosters exist; they have to be read against each other. + */ + test("the holder CHECK admits exactly the two holders", async () => { + expect( + await checkRoster(database, "pending_authorizations_holder_check"), + ).toEqual(["deployment", "person"]); + }); + + test("the outcome CHECK admits exactly the four outcomes", async () => { + expect( + await checkRoster(database, "pending_authorizations_outcome_check"), + ).toEqual(["completed", "failed", "unreconciled", "withdrawn"]); + }); + test("the legal shapes are all still accepted", async () => { const action = `legal-${suite}`; await insert({ action, holder: "person", userId: `user-${suite}` }); diff --git a/server/tests/arcade-provider-check.test.ts b/server/tests/arcade-provider-check.test.ts index 1b54dcd7d..09d959011 100644 --- a/server/tests/arcade-provider-check.test.ts +++ b/server/tests/arcade-provider-check.test.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; import { createDatabase } from "../src/db/client"; import { brokeredConnections } from "../src/db/schema/plugins"; +import { checkRoster } from "./support/constraints"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -40,6 +41,22 @@ describe("brokered_connections.provider", () => { ).toBe("inserted"); }); + /** + * The roster, not a sample of it. + * + * The two tests around this one insert one legal value and one junk value, and both of them stay + * green through a migration that quietly admits a THIRD provider: `arcade` still inserts, `nope` + * is still refused, and nothing anywhere reads what the constraint now permits. A provider the + * broker has no adapter for is a row `assertActivatable` counts and no code can serve, so the + * roster is pinned here, read back from the database rather than from the migration that wrote + * it. Growing it is a deliberate act that has to come through this line. + */ + test("the CHECK admits exactly the two providers the broker implements", async () => { + expect( + await checkRoster(database, "brokered_connections_provider_check"), + ).toEqual(["arcade", "composio"]); + }); + test("an unknown provider is refused by the CHECK", async () => { expect( await outcome(() => diff --git a/server/tests/migration-journal.test.ts b/server/tests/migration-journal.test.ts index 2b54e5231..922f5f6d1 100644 --- a/server/tests/migration-journal.test.ts +++ b/server/tests/migration-journal.test.ts @@ -45,6 +45,8 @@ type Snapshot = { name: string; columns?: Record; indexes?: Record; + /** The predicate, not merely the name: see {@link checkDisagreements}. */ + checkConstraints?: Record; } >; }; @@ -152,6 +154,205 @@ test("column declarations exclude the cast on a quoted JSON default", () => { }); }); +/** + * The text between a `(` and the `)` that closes it, with nested parentheses and quoted strings + * kept whole. + * + * A CHECK body cannot be matched by a regular expression, because every interesting one ends in + * `IN ('a', 'b')` and the paren that closes the list is not the paren that closes the check. A + * lazy `\(.*?\)` therefore truncates exactly the predicates whose roster is the thing worth + * guarding. `open` is the index OF the opening paren. + */ +const balanced = (sql: string, open: number): string => { + let depth = 0; + for (let at = open; at < sql.length; at += 1) { + const character = sql[at]; + if (character === "'") { + // A postgres string literal, in which parens mean nothing. '' inside one is an escaped quote, + // and falls out of this loop for free: it closes and immediately reopens. + at += 1; + while (at < sql.length && sql[at] !== "'") at += 1; + continue; + } + if (character === "(") depth += 1; + else if (character === ")") { + depth -= 1; + if (depth === 0) return sql.slice(open + 1, at); + } + } + return sql.slice(open + 1); +}; + +/** Two spellings of one predicate are one predicate; only the whitespace is free to differ. */ +const normalised = (expression: string) => + expression.trim().replace(/\s+/g, " "); + +type Check = { table: string; name: string; value: string }; + +/** + * Every CHECK the migration declares, in both spellings postgres allows. + * + * `generate` writes them inline inside `CREATE TABLE` for a new table and as `ALTER TABLE ... ADD + * CONSTRAINT` for an existing one, so a reader of only one spelling is blind to half the + * migrations in the folder — and `0057` is in the half that would have been missed. + */ +const checksIn = (sql: string): Check[] => { + const found: Check[] = []; + + for (const match of sql.matchAll( + /ALTER TABLE "([^"]+)" ADD CONSTRAINT "([^"]+)" CHECK\s*\(/gi, + )) { + const open = match.index + match[0].length - 1; + found.push({ + table: match[1]!, + name: match[2]!, + value: normalised(balanced(sql, open)), + }); + } + + for (const table of sql.matchAll( + /CREATE TABLE (?:IF NOT EXISTS )?"([^"]+)"\s*\(/gi, + )) { + const body = balanced(sql, table.index + table[0].length - 1); + for (const match of body.matchAll(/CONSTRAINT "([^"]+)" CHECK\s*\(/gi)) { + const open = match.index + match[0].length - 1; + found.push({ + table: table[1]!, + name: match[1]!, + value: normalised(balanced(body, open)), + }); + } + } + + return found; +}; + +/** Constraints the migration drops by name, whatever kind they are; only checks are asked about. */ +const constraintsDropped = (sql: string) => + quoted( + sql, + /ALTER TABLE "([^"]+)" DROP CONSTRAINT (?:IF EXISTS )?"([^"]+)"/gi, + ).map(([table, name]) => `${table}.${name}`); + +const checksOf = (snapshot: Snapshot, table: string) => + new Map( + Object.values(tableIn(snapshot, table)?.checkConstraints ?? {}).map( + (check) => [check.name, normalised(check.value)], + ), + ); + +/** + * What the migration does to the CHECK constraints, against what the snapshots say happened. + * + * THE NAME IS NOT THE PREDICATE, and a CHECK is nothing but its predicate. Widening a roster — + * `IN ('composio', 'arcade')` grown one more member — leaves the constraint present, named the + * same, and refusing every value anybody wrote a test about, so a test that inserts a junk row and + * reads back the name of what refused it stays green through the mutation. The snapshot beside the + * migration is the only artifact that records the predicate itself, and until this check existed + * nothing compared the two: the pair could disagree about what the database is allowed to hold and + * every test in this file went on agreeing. + * + * Read in both directions, like the tables above. A snapshot that records a predicate no statement + * writes leaves `generate` diffing against a constraint no database has; a statement no snapshot + * records has the next `generate` re-emit it, and `ADD CONSTRAINT` is not conditional. + */ +const checkDisagreements = ( + sql: string, + before: Snapshot, + after: Snapshot, + droppedTables: string[], +) => { + const added = checksIn(sql); + const dropped = new Set(constraintsDropped(sql)); + const problems: string[] = []; + const tables = new Set([ + ...tableNames(before), + ...tableNames(after), + ...added.map((check) => check.table), + ]); + + for (const table of tables) { + const was = checksOf(before, table); + const is = checksOf(after, table); + const writes = new Map( + added + .filter((check) => check.table === table) + .map((check) => [check.name, check.value]), + ); + + for (const [name, value] of is) + if (was.get(name) !== value && writes.get(name) !== value) + problems.push( + was.has(name) + ? `the snapshot changes CHECK "${name}" on "${table}", the migration never writes that predicate` + : `the snapshot adds CHECK "${name}" on "${table}", the migration never adds that predicate`, + ); + + for (const [name, value] of writes) + if (is.get(name) !== value) + problems.push( + `the migration adds CHECK "${name}" on "${table}", the snapshot does not record that predicate`, + ); + + for (const [name, value] of was) + if ( + is.get(name) !== value && + !dropped.has(`${table}.${name}`) && + !droppedTables.includes(table) + ) + problems.push( + `the snapshot ${is.has(name) ? "changes" : "drops"} CHECK "${name}" on "${table}", the migration never drops it`, + ); + + for (const name of was.keys()) + if (dropped.has(`${table}.${name}`) && is.get(name) === was.get(name)) + problems.push( + `the migration drops CHECK "${name}" on "${table}", the snapshot still has it unchanged`, + ); + } + + return problems; +}; + +test("a CHECK body is read past the parentheses of its own roster", () => { + expect( + checksIn( + `ALTER TABLE "t" ADD CONSTRAINT "t_check" CHECK ("t"."c" IN ('a', 'b'));`, + ), + ).toEqual([{ table: "t", name: "t_check", value: `"t"."c" IN ('a', 'b')` }]); +}); + +test("a CHECK declared inside CREATE TABLE is read as the table's own", () => { + expect( + checksIn( + `CREATE TABLE "t" (\n\t"c" text NOT NULL,\n\tCONSTRAINT "t_check" CHECK ("t"."c" IN ('a'))\n);`, + ), + ).toEqual([{ table: "t", name: "t_check", value: `"t"."c" IN ('a')` }]); +}); + +test("a widened roster disagrees with the snapshot that records the narrow one", () => { + const snapshot = (value: string): Snapshot => ({ + tables: { + "public.t": { + name: "t", + checkConstraints: { t_check: { name: "t_check", value } }, + }, + }, + }); + + expect( + checkDisagreements( + `ALTER TABLE "t" DROP CONSTRAINT "t_check";--> statement-breakpoint\nALTER TABLE "t" ADD CONSTRAINT "t_check" CHECK ("t"."c" IN ('a', 'b', 'c'));`, + snapshot(`"t"."c" IN ('a')`), + snapshot(`"t"."c" IN ('a', 'b')`), + [], + ), + ).toEqual([ + `the snapshot changes CHECK "t_check" on "t", the migration never writes that predicate`, + `the migration adds CHECK "t_check" on "t", the snapshot does not record that predicate`, + ]); +}); + const everyIndexName = (snapshot: Snapshot) => new Set( Object.values(snapshot.tables ?? {}).flatMap((table) => @@ -300,6 +501,8 @@ const disagreements = (sql: string, before: Snapshot, after: Snapshot) => { `the migration drops index "${index}", the snapshot still has it`, ); + problems.push(...checkDisagreements(sql, before, after, dropped)); + return problems; }; diff --git a/server/tests/support/constraints.ts b/server/tests/support/constraints.ts new file mode 100644 index 000000000..66e0955a9 --- /dev/null +++ b/server/tests/support/constraints.ts @@ -0,0 +1,43 @@ +// server/tests/support/constraints.ts +// +// What a CHECK constraint ACTUALLY says in the database the tests are pointed at. +// +// A test that inserts a junk value and reads back the name of the constraint that refused it says +// two things: the constraint exists, and it refuses that one value. It says nothing about the +// roster, so widening one — `IN ('composio', 'arcade')` grown a third member nobody reviewed — +// leaves every such test green. That is a real failure mode and not a hypothetical: it is what a +// mutation of `0057_arcade_provider.sql` did, with the schema guards all passing. +// +// So ask postgres for the predicate itself. `pg_get_constraintdef` renders what the server stores, +// whatever spelling the migration used to write it, which also means the comparison survives +// `IN (...)` being recorded as `= ANY (ARRAY[...])`. +import { sql } from "drizzle-orm"; +import type { createDatabase } from "../src/db/client"; + +type Database = ReturnType; + +/** + * The string literals a CHECK constraint names, sorted, so a test can pin the roster exactly. + * + * Throws when no CHECK of that name exists, because a renamed or missing constraint is the louder + * half of the same mistake and must not read as an empty roster that an `toEqual([])` could match. + */ +export const checkRoster = async ( + database: Database, + constraint: string, +): Promise => { + const rows = await database.execute(sql` + SELECT pg_get_constraintdef(oid) AS definition + FROM pg_constraint + WHERE conname = ${constraint} AND contype = 'c' + `); + const definition = rows[0]?.definition as string | undefined; + if (!definition) { + throw new Error( + `no CHECK constraint named "${constraint}" in this database; it was renamed, dropped, or its migration never ran`, + ); + } + return [...definition.matchAll(/'((?:[^']|'')*)'/g)] + .map((match) => match[1]!.replace(/''/g, "'")) + .sort(); +}; From fe601cde2061cb1ff4bae22fa34047716a51e8ec Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:27:07 +0200 Subject: [PATCH 145/232] fix(tests): make the D24 pairing guard able to fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `broker-authorization-pairing.test.ts` asserted the pairing invariant for nothing. Two defects hid each other: 1. `mints` skipped any authorize whose first three body lines contain a `throw`. Across the whole tree that heuristic excluded exactly one literal — `src/plugins/arcade-adapter.ts`'s authorize, the only production broker with a non-null `authorizations`, i.e. the one the invariant exists for. Every stub it "excluded" was already excluded by having no `redirectUrl` at all, so the term bought nothing and cost the adapter. 2. `/authorizationId:/` requires a colon, so it could not see that adapter's shorthand `return { redirectUrl: url, authorizationId }`. Fixing (1) alone would therefore have turned the guard RED on correct code. The guard now anchors on the `authorize` member (confirmed a broker by a `grantIsLocal`/`authorizations` sibling, and not a `;`-terminated interface declaration), reads only the object literals the body can ANSWER with (`return {…}`, `=> ({…})`, comments stripped), and classifies the id as none/null/value so a shorthand reads as the non-null answer it is. Shapes it cannot read now throw instead of being skipped. Same-pattern audit, all fixed in this file: - the `grantIsLocal:`-anchored literal finder missed `tests/gate-grant-is-local.test.ts`'s `Omit` stub entirely; `unreachedIn()` now fails if a broker-shaped `grantIsLocal` declaration has no scanned `authorize`. - the hand-listed `tests/` + `src/plugins/` directories are now a recursive sweep of `tests/` and `src/`. - the `>= 14` floor (against 26 checked) is now `>= 25` against 28, plus an assertion that BOTH the null and flow branches are inhabited and that the two production adapters are among the literals CHECKED, not merely found. Evidence: with `arcade-adapter.ts` temporarily answering `authorizationId: null` (type-legal, invariant-breaking) the guard fails naming `src/plugins/arcade-adapter.ts:796`; restored, 30 pass / 0 fail. `arcade-adapter.ts` and `composio-adapter.ts` are byte-identical to a116c753. Symbols: every symbol added or changed is module-local to this test file and has no call sites outside it — added `sources`, `blockAt`, `MEMBER`, `stripComments`, `answersOf`, `unreachedIn`, `idOf`, `minting`, `coverage`; `literalsIn` resignatured (its `Literal` now carries `answers: string[]` in place of `authorize: string`); `indentOf`, `literals`, `files`, `ROOT`, `SELF` unchanged in meaning. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- .../broker-authorization-pairing.test.ts | 244 +++++++++++++----- 1 file changed, 180 insertions(+), 64 deletions(-) diff --git a/server/tests/broker-authorization-pairing.test.ts b/server/tests/broker-authorization-pairing.test.ts index 931f2ed15..df1ae6f6a 100644 --- a/server/tests/broker-authorization-pairing.test.ts +++ b/server/tests/broker-authorization-pairing.test.ts @@ -10,111 +10,227 @@ import { join } from "node:path"; * one way spec D24's binding (the attempt's `ar_…` equals the callback's `auth_id`) rots without a * type error: an adapter grows a flow surface and forgets the mint id. * - * A literal is found by its `grantIsLocal:` member, and its members are the lines at that same - * indent. This reads source text because the stubs are module-local and `server/tests` is outside - * `tsc`, so nothing else can see them all. + * A literal is found by its `authorize` member — the one member every broker must have — and is + * confirmed a broker by a `grantIsLocal` or `authorizations` sibling at the same indent. This reads + * source text because the stubs are module-local and `server/tests` is outside `tsc`, so nothing + * else can see them all. + * + * EVERY HEURISTIC HERE FAILS LOUD, NEVER QUIET. A guard that decides for itself which code to look + * at is only as good as that decision, and a decision that silently drops the one adapter the + * invariant exists for reads exactly like a pass. So: the shape this scan cannot read throws + * instead of being skipped ({@link answersOf}), a broker-shaped literal with no `authorize` throws, + * and {@link coverage} asserts by name that the production adapters are among the literals this + * file actually checks rather than merely among the ones it found. */ const ROOT = join(import.meta.dir, ".."); const SELF = "broker-authorization-pairing.test.ts"; -const files = [ - ...readdirSync(join(ROOT, "tests")).map((f) => join(ROOT, "tests", f)), - ...readdirSync(join(ROOT, "src", "plugins")).map((f) => - join(ROOT, "src", "plugins", f), - ), -].filter((f) => f.endsWith(".ts") && !f.endsWith(SELF)); +/** + * THE WHOLE OF `src` AND `tests`, RECURSIVELY. A hand-listed directory is the same silent miss as a + * hand-tuned heuristic: a broker literal written one directory over would simply not be asserted, + * and that reads as a pass. + */ +const sources = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() + ? sources(join(dir, e.name)) + : e.name.endsWith(".ts") && e.name !== SELF + ? [join(dir, e.name)] + : [], + ); +const files = [...sources(join(ROOT, "tests")), ...sources(join(ROOT, "src"))]; type Literal = { where: string; authorizations: "null" | "flow"; - authorize: string; + /** The object literals this `authorize` can ANSWER WITH; see {@link answersOf}. */ + answers: string[]; }; const indentOf = (l: string) => /^\s*/.exec(l)![0].length; +/** + * A member line of an object literal: `name:`, `name(`, `async name(`, or the shorthand `name,`. + * The shorthand matters for the member AFTER `authorize`, which is where its body ends. + */ +const MEMBER = /^\s*(async\s+)?\w+\??\s*([(:]|,\s*$|$)/; + +/** The lines of the object literal whose members sit at `indent`, and which of them are members. */ +function blockAt(lines: string[], at: number, indent: number) { + let from = at; + while ( + from > 0 && + (lines[from - 1]!.trim() === "" || indentOf(lines[from - 1]!) >= indent) + ) + from--; + let to = at; + while ( + to < lines.length - 1 && + (lines[to + 1]!.trim() === "" || indentOf(lines[to + 1]!) >= indent) + ) + to++; + const block = lines.slice(from, to + 1); + const members = block + .map((l, i) => ({ l, i })) + .filter(({ l }) => indentOf(l) === indent && MEMBER.test(l)); + const named = (name: string) => + members.findIndex(({ l }) => + new RegExp(`^\\s*(async\\s+)?${name}\\s*([(:]|,\\s*$|$)`).test(l), + ); + return { from, block, members, named }; +} + +/** + * Comments are prose: `authorizationId` in a sentence is not an answer. The `//` strip refuses a + * `//` that follows a colon so that a `"https://…"` inside a returned literal survives — eating it + * would unbalance the braces this reads next. + */ +const stripComments = (s: string) => + s.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1"); + +/** + * The object literals an `authorize` body can ANSWER WITH: `return {…}` and the concise `=> ({…})`. + * Scoping to these is what keeps the seam's own `authorizationId: string | null` annotation, a + * local `const authorizationId`, or a sentence in a comment from reading as an answer — and what + * lets a shorthand `return { redirectUrl, authorizationId }` be read as the answer it is. + * + * `=> {` is a function body, not an answer; its `return`s are found by the `return` pass. + */ +function answersOf(body: string): string[] { + const src = stripComments(body); + const out: string[] = []; + for (const m of src.matchAll(/\breturn\b|=>\s*\(/g)) { + let i = m.index + m[0].length; + while (i < src.length && /[\s(]/.test(src[i]!)) i++; + if (src[i] !== "{") continue; + let depth = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}" && --depth === 0) { + out.push(src.slice(i, j + 1)); + break; + } + } + } + return out; +} + function literalsIn(file: string): Literal[] { const lines = readFileSync(file, "utf8").split("\n"); const out: Literal[] = []; lines.forEach((line, at) => { - const m = /^(\s*)grantIsLocal:\s*(true|false),?\s*$/.exec(line); + const m = /^(\s*)(async\s+)?authorize\s*[(:]/.exec(line); if (!m) return; const indent = m[1]!.length; - let from = at; - while ( - from > 0 && - (lines[from - 1]!.trim() === "" || indentOf(lines[from - 1]!) >= indent) - ) - from--; - let to = at; - while ( - to < lines.length - 1 && - (lines[to + 1]!.trim() === "" || indentOf(lines[to + 1]!) >= indent) - ) - to++; - const block = lines.slice(from, to + 1); - const members = block - .map((l, i) => ({ l, i })) - .filter( - ({ l }) => - indentOf(l) === indent && /^\s*(async\s+)?\w+\??\s*[(:]/.test(l), - ); - const named = (name: string) => - members.findIndex(({ l }) => - new RegExp(`^\\s*(async\\s+)?${name}\\s*[(:]`).test(l), - ); + const { from, block, members, named } = blockAt(lines, at, indent); + const g = named("grantIsLocal"); const a = named("authorizations"); + // An `authorize` with neither sibling is somebody else's member — a vendor client's own + // `authorize`, say — and not the seam this invariant is about. + if (g < 0 && a < 0) return; + // The seam's own interface declares these members too (`grantIsLocal: boolean;`). A declaration + // is not a broker: the pairing lives in the literals that implement it, and a `;`-terminated + // member is how a declaration gives itself away. + if ( + [g, a].some((i) => i >= 0 && /;\s*$/.test(block[members[i]!.i]!.trim())) + ) + return; const z = named("authorize"); - if (z < 0) { - throw new Error( - `${file}:${at + 1}: a broker literal with no authorize member`, - ); - } // The one literal that omits `authorizations` is composio-grants-for's deliberate // does-not-compile witness, which is then spread with `authorizations: null` — so an omission - // is read as the null it is completed with there. + // is read as the null it is completed with there. That reading is the strict one: it demands + // `authorizationId: null`, so a literal that omits the member AND mints an id fails loudly. const aLine = a < 0 ? "authorizations: null" : block[members[a]!.i]!; const end = z + 1 < members.length ? members[z + 1]!.i : block.length; + const body = block.slice(members[z]!.i, end).join("\n"); + const answers = answersOf(body); + // A SHAPE THIS CANNOT READ THROWS. `redirectUrl` in the body with no answer carrying it means + // the mint is assembled somewhere this scan does not look, and silently skipping it is how a + // guard comes to assert nothing. + if ( + /\bredirectUrl\b/.test(stripComments(body)) && + !answers.some((ans) => /\bredirectUrl\b/.test(ans)) + ) { + throw new Error( + `${file.slice(ROOT.length + 1)}:${from + members[z]!.i + 1}: this authorize names redirectUrl but answers with a shape this guard cannot read; teach answersOf() the shape rather than leaving the literal unchecked.`, + ); + } out.push({ where: `${file.slice(ROOT.length + 1)}:${from + members[z]!.i + 1}`, authorizations: /authorizations:\s*null\b/.test(aLine) ? "null" : "flow", - authorize: block.slice(members[z]!.i, end).join("\n"), + answers, }); }); return out; } +/** + * Every declared `grantIsLocal` on a broker-shaped literal must have landed in {@link literalsIn}'s + * net. This is the net's own guard: the scan anchors on `authorize`, so a broker literal that grew + * one without the other would otherwise go unexamined and read as a pass. + */ +function unreachedIn(file: string): string[] { + const lines = readFileSync(file, "utf8").split("\n"); + const out: string[] = []; + lines.forEach((line, at) => { + const m = /^(\s*)grantIsLocal\s*:\s*(true|false)\s*[,}]/.exec(line); + if (!m) return; + const { named } = blockAt(lines, at, m[1]!.length); + // `{ key, grantIsLocal: true }` in a deployment config is not a broker. + if (named("authorizations") < 0) return; + if (named("authorize") < 0) + out.push(`${file.slice(ROOT.length + 1)}:${at + 1}`); + }); + return out; +} + +/** `none` is no `authorizationId` at all, which answers neither surface. */ +function idOf(answer: string): "none" | "null" | "value" { + const m = /\bauthorizationId\b\s*(?::\s*([^,}\n]+))?/.exec(answer); + if (!m) return "none"; + if (m[1] === undefined) return "value"; // the shorthand `{ …, authorizationId }` + return /^null\s*$/.test(m[1]) ? "null" : "value"; +} + const literals = files.flatMap(literalsIn); +/** The literals actually asserted over: the ones whose `authorize` answers a url to visit. */ +const minting = literals + .map((l) => ({ + ...l, + answers: l.answers.filter((a) => /\bredirectUrl\b/.test(a)), + })) + .filter((l) => l.answers.length > 0); +const coverage = minting.map((l) => l.where); describe("authorizations and authorizationId are declared as a pair", () => { - test("the scan finds the production adapters and the test stubs", () => { - const places = literals.map((l) => l.where); + test("the scan reaches every broker-shaped literal", () => { + expect(files.flatMap(unreachedIn)).toEqual([]); + }); + + test("the production adapters are among the literals CHECKED", () => { + // Not merely among the ones found: the Arcade adapter is the one production broker with a + // non-null `authorizations`, so if it is not here the flow half of this invariant is asserted + // for nothing but stubs. expect( - places.some((p) => p.startsWith("src/plugins/arcade-adapter.ts")), + coverage.some((p) => p.startsWith("src/plugins/arcade-adapter.ts")), ).toBe(true); expect( - places.some((p) => p.startsWith("src/plugins/composio-adapter.ts")), + coverage.some((p) => p.startsWith("src/plugins/composio-adapter.ts")), ).toBe(true); - expect(literals.length).toBeGreaterThanOrEqual(14); + // A floor that notices this scan collapsing, not a count to keep up to date; it sits just under + // what the tree holds today (28) rather than at a third of it. + expect(minting.length).toBeGreaterThanOrEqual(25); + // BOTH BRANCHES MUST BE INHABITED. An empty one is a branch asserted for nothing, which is the + // state this guard was in when its only non-null-`authorizations` literal was being skipped. + expect(minting.some((l) => l.authorizations === "null")).toBe(true); + expect(minting.some((l) => l.authorizations === "flow")).toBe(true); }); - for (const literal of literals) { + for (const literal of minting) { test(literal.where, () => { - // A stub that never mints (it throws, or is `unasked`) answers no url, so the pair is moot. - const head = literal.authorize.split("\n").slice(0, 3).join("\n"); - const mints = - /redirectUrl/.test(literal.authorize) && - !/\bthrow\b|unasked\(/.test(head); - if (!mints) return; - // The seam type's own `authorizationId: string | null` annotation is not an answer. - const code = literal.authorize.replaceAll( - "authorizationId: string | null", - "", - ); - if (literal.authorizations === "null") { - expect(code).toMatch(/authorizationId:\s*null/); - expect(code).not.toMatch(/authorizationId:(?!\s*null)/); - } else { - expect(code).toMatch(/authorizationId:/); - expect(code).not.toMatch(/authorizationId:\s*null/); + const want = literal.authorizations === "null" ? "null" : "value"; + for (const answer of literal.answers) { + const said = answer.replace(/\s+/g, " "); + expect(`${idOf(answer)} in ${said}`).toBe(`${want} in ${said}`); } }); } From 5ac5eba0b5e217bd39c78208ea91f7d067c4377e Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:28:10 +0200 Subject: [PATCH 146/232] fix(app): read search parameters as text, so a numeric handle is not dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router JSON-parses every search value. `?flow_id=12345` reaches /sign as the number 12345 and `?flow_id={"a":1}` as an object; `validateSearch` guarded on `typeof search.flow_id === "string"` and omitted the key for both. `flow_id` is the correlation handle on the OAuth return leg, so for whichever handles the vendor's generator happens to emit, sign-in redirected to `/` instead of the verifier and the return leg was bound to no attempt at all. Which ids are affected depends on the vendor, so it is green in testing and lossy in production. Fixed by construction rather than by widening the guard, the way the notice lookup on the same screen was: `searchText` renders whatever the router parsed back to the text it was in the URL, so there is no shape a sender can choose that falls through to "no parameter". `parseSearchText` takes away the one case text could not have been recovered from — a number whose own text form differs from what it was parsed from, e.g. an id past 2^53 or `1e3` — by leaving that value as the string it was. Same-pattern audit: every hand-written `validateSearch` that declared a string and dropped anything else now reads it through `searchTextEntry` — `flow_id` (/sign), `connected`/`authorized`/`linked` (connected accounts), `action` (connected accounts/$key), `agent` (/bot, /channel/new). `?watch=` on /bot is a flag, not text, and still reads the parsed boolean. The zod schemas on /skills, /agents, /bots and /channel/$channelId are left alone: they refuse a coerced value loudly rather than dropping it silently, which is a different finding. A guard test fails any future `typeof search.x === "string"` under app/src/routes. Call sites of the symbols added (none renamed, resignatured or removed): - searchText (new, app/src/lib/search-text.ts): app/src/lib/search-text.ts (searchTextEntry), app/tests/search-text.test.ts. - searchTextEntry (new): app/src/routes/sign.tsx, app/src/routes/_authed/settings/connected-accounts/index.tsx (x3), app/src/routes/_authed/settings/connected-accounts/$key.tsx, app/src/routes/_authed/_app/bot.tsx, app/src/routes/_authed/_app/channel/new.tsx, app/tests/search-text.test.ts. - parseSearchText (new): app/src/router.tsx, app/tests/search-text.test.ts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- app/src/lib/search-text.ts | 73 +++++++++++++ app/src/router.tsx | 7 ++ app/src/routes/_authed/_app/bot.tsx | 9 +- app/src/routes/_authed/_app/channel/new.tsx | 11 +- .../settings/connected-accounts/$key.tsx | 21 ++-- .../settings/connected-accounts/index.tsx | 16 +-- app/src/routes/sign.tsx | 11 +- .../connected-account-confirmation.test.tsx | 10 +- ...nected-accounts-authorized-notice.test.tsx | 20 ++++ app/tests/search-text.test.ts | 100 ++++++++++++++++++ app/tests/sign-resume.test.tsx | 26 ++++- 11 files changed, 277 insertions(+), 27 deletions(-) create mode 100644 app/src/lib/search-text.ts create mode 100644 app/tests/search-text.test.ts diff --git a/app/src/lib/search-text.ts b/app/src/lib/search-text.ts new file mode 100644 index 000000000..8de69c23f --- /dev/null +++ b/app/src/lib/search-text.ts @@ -0,0 +1,73 @@ +import { parseSearchWith } from "@tanstack/react-router"; + +/** + * Reading a search parameter that is TEXT, when the router has already decided it was something + * else. + * + * THE ROUTER JSON-PARSES EVERY SEARCH VALUE. `?connected=notion` stays the string it looks like + * only because `notion` is not valid JSON; `?flow_id=12345` arrives as the number 12345 and + * `?flow_id={"a":1}` as an object, because both of those are. Nothing in the URL said so — whether + * a value survives as text depends entirely on what characters the sender's id generator happened + * to emit, which is why a `typeof value === "string"` guard works for every handle in testing and + * silently drops a fraction of the real ones. + * + * SO NOTHING HERE GUARDS ON THE PARSED TYPE; it renders whatever came back to the text it was in + * the URL. There is no shape a sender can choose that falls through to "no parameter", so the + * wrong case is gone by construction rather than by a list of types to also accept — the same + * answer the notice table on the connected-accounts screen gives for keys off a URL. + */ + +/** + * The text a search value was in the URL, or undefined when the parameter was not there at all. + * + * Exact for everything `parseSearchText` lets through: a string is itself, a number is one it + * round-trips through (see below), and an object or array is re-rendered as the JSON it was parsed + * from. The empty string is a value, not an absence — a parameter present and empty is passed on as + * such, for whoever reads it to call unusable. + */ +export function searchText(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || value === null) + return String(value); + /* + * An object or array can only have got here by having been JSON in the URL, so stringifying it is + * reading that JSON back. `JSON.stringify` answers undefined rather than text for values it + * cannot render; none of those can come out of `JSON.parse`, and the check is here so that a + * future parser which can is a missing parameter rather than the string "undefined". + */ + const text = JSON.stringify(value); + return typeof text === "string" ? text : undefined; +} + +/** + * That text under its key, or nothing — ready to spread into what `validateSearch` returns. + * + * The key is OMITTED rather than set to undefined, for the reason the routes using this give: + * present-but-undefined makes `search` a required prop on every Link to the route, which is a lot + * of ripple for a parameter only a callback sets. + */ +export function searchTextEntry( + key: Key, + value: unknown, +): Partial> { + const text = searchText(value); + return text === undefined ? {} : ({ [key]: text } as Record); +} + +/** + * The router's own parser, with the one case `searchText` could not have rendered back taken away. + * + * `JSON.parse` turns `12345678901234567890` into a double, and no amount of care downstream can + * recover the digits that rounding threw away — a handle like that would come back out of + * `searchText` as a DIFFERENT handle, which is worse than dropping it. So a number whose text form + * is not the text it was parsed from is left as that text, and every number that does reach a route + * is one `String` renders back exactly. `1e3` keeps its spelling for the same reason. + */ +export const parseSearchText: (searchStr: string) => Record = + parseSearchWith((value: string) => { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "number" && String(parsed) !== value + ? value + : parsed; + }); diff --git a/app/src/router.tsx b/app/src/router.tsx index 4d7e914fe..acab7a7bc 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -1,10 +1,17 @@ import { createRouter } from "@tanstack/react-router"; +import { parseSearchText } from "./lib/search-text"; import type { RouterContext } from "./router-context"; import { routeTree } from "./routeTree.gen"; export const router = createRouter({ routeTree, context: {} as RouterContext, + /* + * The router's own search parsing, minus the numbers it cannot render back: see + * `parseSearchText`. Every route that reads a parameter as text reads it through `searchText`, + * which needs what arrives to be something that text can be recovered from. + */ + parseSearch: parseSearchText, }); declare module "@tanstack/react-router" { diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx index 128eb0197..c64ff8263 100644 --- a/app/src/routes/_authed/_app/bot.tsx +++ b/app/src/routes/_authed/_app/bot.tsx @@ -17,13 +17,20 @@ import { tryClient } from "@/lib/client"; import { useActiveBot } from "@/lib/copilot/active-bot"; import { useBotThread } from "@/lib/copilot/bot-thread"; import { useStoppedTurn } from "@/lib/copilot/stopped-turn"; +import { searchTextEntry } from "@/lib/search-text"; export const Route = createFileRoute("/_authed/_app/bot")({ component: RouteComponent, + /* + * `?agent=` is read as text whatever the router parsed it into (`searchText`), because search + * values are JSON-parsed and a Bot id made of digits would otherwise arrive as a number and open + * this deployment's default Bot rather than the named one. `?watch=` is a flag and is read as the + * boolean it is parsed into: anything else is not the flag being set. + */ validateSearch: ( search: Record, ): { agent?: string; watch?: boolean } => ({ - ...(typeof search.agent === "string" ? { agent: search.agent } : {}), + ...searchTextEntry("agent", search.agent), ...(search.watch === true ? { watch: true } : {}), }), }); diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx index 4193fee5e..c5fa0341a 100644 --- a/app/src/routes/_authed/_app/channel/new.tsx +++ b/app/src/routes/_authed/_app/channel/new.tsx @@ -31,6 +31,7 @@ import { } from "@/lib/agents/queries"; import { useStartChannel } from "@/lib/channels/start"; import { useSkillCommands } from "@/lib/plugins/skill-commands"; +import { searchTextEntry } from "@/lib/search-text"; import { newId } from "../../../../lib/new-id"; /** @@ -42,9 +43,13 @@ import { newId } from "../../../../lib/new-id"; const AGENT_NOT_FOUND = "Agent not found."; export const Route = createFileRoute("/_authed/_app/channel/new")({ - validateSearch: (search: Record): { agent?: string } => ({ - ...(typeof search.agent === "string" ? { agent: search.agent } : {}), - }), + /* + * The pending recipient's id, read as text whatever the router parsed it into (`searchText`): + * search values are JSON-parsed, so a Bot id made of digits would otherwise arrive as a number + * and the link would open an empty composer instead of the one it names. + */ + validateSearch: (search: Record): { agent?: string } => + searchTextEntry("agent", search.agent), component: RouteComponent, }); diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 775f63b63..390b49d91 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -30,6 +30,13 @@ import { import { Separator } from "@/components/ui/separator"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { connectAccountMutationOptions } from "@/lib/plugins/mutations"; +import { + brokeredAccountPageDescription, + brokeredConsentTrip, + brokeredDisconnectNote, + brokeredProvenance, + disconnectSiblingsNotice, +} from "@/lib/plugins/provider-vocabulary"; import { connectionSiblingsQueryOptions, connectionsQueryOptions, @@ -38,13 +45,7 @@ import { pluginsPageQueryOptions, pluginsProvider, } from "@/lib/plugins/queries"; -import { - brokeredAccountPageDescription, - brokeredConsentTrip, - brokeredDisconnectNote, - brokeredProvenance, - disconnectSiblingsNotice, -} from "@/lib/plugins/provider-vocabulary"; +import { searchTextEntry } from "@/lib/search-text"; /** * Whether this server is reached through Composio AS THIS PERSON. @@ -105,9 +106,13 @@ export const Route = createFileRoute( * `?action=` is the verifier's redirect saying WHICH action the consent just completed was for, * so the page can land on it rather than on the app. The key is omitted when absent rather than * set to undefined, which would make `search` a required prop on every Link to this route. + * + * Read as text whatever the router parsed it into (`searchText`): an action name made of digits + * or shaped like JSON arrives as a number or an object, and a `typeof` guard would land the page + * on the app as though the verifier had never said which action it was. */ validateSearch: (search: Record): { action?: string } => - typeof search.action === "string" ? { action: search.action } : {}, + searchTextEntry("action", search.action), }); function RouteComponent() { diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 52ecbfd12..9f0a99257 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -42,6 +42,7 @@ import { personalConnections, pluginsPageQueryOptions, } from "@/lib/plugins/queries"; +import { searchTextEntry } from "@/lib/search-text"; import { clearTeamBotConsentsMutationOptions } from "@/lib/team-bots"; /** @@ -63,23 +64,24 @@ export const Route = createFileRoute("/_authed/settings/connected-accounts/")({ * * The key is omitted rather than set to undefined. Present-but-undefined makes `search` a required * prop on every Link to this route, which is a lot of ripple for a parameter only the callback sets. + * + * EACH IS READ AS TEXT WHATEVER THE ROUTER PARSED IT INTO (`searchText`): the router JSON-parses + * search values, so a server key or reason code that happens to be digits or JSON-shaped arrives + * as a number or an object, and a `typeof` guard would quietly turn the callback's report into + * no report at all. */ validateSearch: ( search: Record, ): { connected?: string; authorized?: string; linked?: string } => ({ - ...(typeof search.connected === "string" - ? { connected: search.connected } - : {}), + ...searchTextEntry("connected", search.connected), /* * A CHANNEL OF ITS OWN, BESIDE `?connected=` AND NEVER FOLDED INTO IT. That one is the Composio * OAuth callback's and carries a server key or `failed`; this one is the brokered per-action * verifier's and carries a reason code. Two senders answering different questions on one * parameter is how a notice comes to be drawn for the wrong thing. */ - ...(typeof search.authorized === "string" - ? { authorized: search.authorized } - : {}), - ...(typeof search.linked === "string" ? { linked: search.linked } : {}), + ...searchTextEntry("authorized", search.authorized), + ...searchTextEntry("linked", search.linked), }), }); diff --git a/app/src/routes/sign.tsx b/app/src/routes/sign.tsx index 5202836e9..db46087be 100644 --- a/app/src/routes/sign.tsx +++ b/app/src/routes/sign.tsx @@ -14,6 +14,7 @@ import { } from "@/lib/auth/client"; import { resumeUrlFor, verifierPathFor } from "@/lib/auth/resume"; import { appConfig } from "@/lib/generated/application-config"; +import { searchTextEntry } from "@/lib/search-text"; import { type AuthProviderId, authProvidersQueryOptions, @@ -31,13 +32,17 @@ export const Route = createFileRoute("/sign")({ * `?flow_id=` is the handle a brokered authorization arrived with, sent here when its session had * lapsed. It is a value to carry back, never a place to go: see `resumeUrlFor`. * + * READ AS TEXT WHATEVER THE ROUTER PARSED IT INTO, which is what `searchText` is for: a handle + * made of digits arrives here as a number and a JSON-shaped one as an object, so a `typeof` + * guard would drop the correlation for whichever handles the vendor's generator happens to emit + * — working in testing and losing the return leg's binding in production. + * * The key is omitted rather than set to undefined, for the reason the connected-accounts screen * gives for `?connected=`: present-but-undefined makes `search` a required prop on every Link to * this route, which is a lot of ripple for a parameter only the verifier sets. */ - validateSearch: (search: Record): { flow_id?: string } => ({ - ...(typeof search.flow_id === "string" ? { flow_id: search.flow_id } : {}), - }), + validateSearch: (search: Record): { flow_id?: string } => + searchTextEntry("flow_id", search.flow_id), beforeLoad: async ({ context, search }) => { const user = await context.queryClient.ensureQueryData( currentUserQueryOptions(), diff --git a/app/tests/connected-account-confirmation.test.tsx b/app/tests/connected-account-confirmation.test.tsx index 8d1da4706..e7e1c9c75 100644 --- a/app/tests/connected-account-confirmation.test.tsx +++ b/app/tests/connected-account-confirmation.test.tsx @@ -376,7 +376,7 @@ test("a landing without an action behaves as it did", async () => { expect(view.container.textContent).not.toContain("just authorized"); }); -test("validateSearch omits the key when absent and keeps a string action", () => { +test("validateSearch omits the key when absent and reads the action as text", () => { const validate = ( ConnectedAccountRoute as unknown as { options: { validateSearch: (s: Record) => object }; @@ -384,10 +384,16 @@ test("validateSearch omits the key when absent and keeps a string action", () => ).options.validateSearch; expect(validate({})).toEqual({}); expect("action" in validate({})).toBe(false); - expect(validate({ action: 5 })).toEqual({}); expect(validate({ action: "Gmail.ListEmails" })).toEqual({ action: "Gmail.ListEmails", }); + /* + * The router JSON-parses search values, so an action name made of digits arrives as a number and + * a JSON-shaped one as an object. Both are still what the verifier put in the URL: the key is + * read as text (`searchText`) rather than dropped for having parsed into something else. + */ + expect(validate({ action: 5 })).toEqual({ action: "5" }); + expect(validate({ action: { a: 1 } })).toEqual({ action: '{"a":1}' }); }); test("an empty or absurdly long action is not drawn", () => { diff --git a/app/tests/connected-accounts-authorized-notice.test.tsx b/app/tests/connected-accounts-authorized-notice.test.tsx index 1603c1ba6..4eefdc289 100644 --- a/app/tests/connected-accounts-authorized-notice.test.tsx +++ b/app/tests/connected-accounts-authorized-notice.test.tsx @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { AUTHORIZATION_NOTICES, authorizationNoticeFor, + Route, } from "@/routes/_authed/settings/connected-accounts/index"; /** @@ -110,3 +111,22 @@ test.each([ if (notice !== null) expect(typeof notice).toBe("string"); }, ); + +/** + * AND THE CODE REACHES THAT LOOKUP AT ALL, whatever the router parsed the parameter into. + * + * Search values are JSON-parsed, so a reason code or server key made of digits arrives as a number + * and a JSON-shaped one as an object. All three parameters on this screen are read as text + * (`searchText`), so what the callback reported is what the screen decides on rather than silently + * nothing at all. + */ +test("every parameter on this screen is read as text, whatever it parsed into", () => { + const validate = Route.options.validateSearch as (s: unknown) => object; + expect( + validate({ connected: 12345, authorized: 678, linked: { a: 1 } }), + ).toEqual({ connected: "12345", authorized: "678", linked: '{"a":1}' }); + expect(validate({ authorized: "no-attempt" })).toEqual({ + authorized: "no-attempt", + }); + expect(validate({})).toEqual({}); +}); diff --git a/app/tests/search-text.test.ts b/app/tests/search-text.test.ts new file mode 100644 index 000000000..4cc3f4ff9 --- /dev/null +++ b/app/tests/search-text.test.ts @@ -0,0 +1,100 @@ +// app/tests/search-text.test.ts +/** + * Search parameters that are TEXT survive the router's JSON parsing. + * + * The router JSON-parses every search value, so `?flow_id=12345` reaches a route as the number + * 12345 and `?flow_id={"a":1}` as an object. A route that guarded on `typeof value === "string"` + * dropped exactly those, which for the OAuth return leg's `flow_id` means the correlation handle + * vanishes for whichever ids the vendor's generator happens to emit: green in testing, and a return + * leg bound to nothing for a fraction of real ids in production. + * + * Two halves are pinned here. `searchText` renders whatever arrived back to the text it was, and + * `parseSearchText` takes away the one case it could not have rendered back exactly — a number too + * long to survive a double. The guard at the bottom keeps the `typeof` form from coming back. + */ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + parseSearchText, + searchText, + searchTextEntry, +} from "@/lib/search-text"; + +test("text is recovered from whatever the router parsed the value into", () => { + expect(searchText("abc")).toBe("abc"); + expect(searchText(12345)).toBe("12345"); + expect(searchText(true)).toBe("true"); + expect(searchText(null)).toBe("null"); + expect(searchText({ a: 1 })).toBe('{"a":1}'); + expect(searchText(["a"])).toBe('["a"]'); + // Present and empty is a value, not an absence; only an absent parameter is undefined. + expect(searchText("")).toBe(""); + expect(searchText(undefined)).toBeUndefined(); +}); + +test("the entry carries the key or omits it, never sets it to undefined", () => { + expect(searchTextEntry("flow_id", 12345)).toEqual({ flow_id: "12345" }); + expect(searchTextEntry("flow_id", undefined)).toEqual({}); + expect("flow_id" in searchTextEntry("flow_id", undefined)).toBe(false); +}); + +/* + * ROUND TRIP, which is the whole claim: what the verifier put in the URL is what a route reads. + * `parseSearchText` is the router's own parsing, so this is the real path a browser takes. + */ +test("a handle survives the URL whatever it is made of", () => { + const handles = [ + "fl_01HQ8Z", + "12345", + "0123", + "1e3", + "12345678901234567890", + '{"a":1}', + "true", + "null", + "a b&c/../d", + ]; + for (const handle of handles) { + const url = `?flow_id=${encodeURIComponent(handle)}`; + expect(searchText(parseSearchText(url).flow_id)).toBe(handle); + } +}); + +test("a parameter that is not there stays not there", () => { + expect(searchText(parseSearchText("?other=1").flow_id)).toBeUndefined(); +}); + +/** + * AND THE SHAPE THAT CAUSED IT DOES NOT COME BACK. A `typeof search.x === "string"` test in a + * route's `validateSearch` is the defect itself: it reads "keep it when it is text" and means "drop + * it whenever the sender's value happened to be valid JSON". Routes read text through `searchText`. + */ +const ROUTES_DIR = join(import.meta.dir, "../src/routes"); +const DROPPED_STRING = /typeof\s+search\??\.\w+\s*===\s*["']string["']/; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else if (/\.tsx?$/.test(entry.name)) out.push(full); + } + return out.sort(); +} + +test("no route reads a search parameter by guarding on its parsed type", () => { + const files = walk(ROUTES_DIR); + expect(files.length).toBeGreaterThan(10); + const offenders: string[] = []; + for (const file of files) { + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + if (DROPPED_STRING.test(line)) { + offenders.push(`${file.slice(ROUTES_DIR.length + 1)}:${index + 1}`); + } + }); + } + expect(offenders).toEqual([]); +}); diff --git a/app/tests/sign-resume.test.tsx b/app/tests/sign-resume.test.tsx index da311f8ba..6af4fe3b3 100644 --- a/app/tests/sign-resume.test.tsx +++ b/app/tests/sign-resume.test.tsx @@ -67,14 +67,34 @@ test("resumeUrlFor puts an encoded handle on the verifier, and is the origin wit ).toBeLessThan(600); }); -test("validateSearch keeps a string flow_id, omits the key otherwise", () => { +test("validateSearch keeps the handle whatever shape the router parsed it into", () => { const validate = Route.options.validateSearch as (s: unknown) => object; expect(validate({ flow_id: "abc" })).toEqual({ flow_id: "abc" }); - expect(validate({ flow_id: 42 })).toEqual({}); - expect("flow_id" in validate({ flow_id: ["a"] })).toBe(false); + /* + * THE ROUTER JSON-PARSES SEARCH VALUES, so a handle the vendor happened to mint out of digits + * arrives as a number and a JSON-shaped one as an object. Both are still the text the verifier + * sent; dropping them is losing the correlation for whichever ids the generator happens to emit. + */ + expect(validate({ flow_id: 12345 })).toEqual({ flow_id: "12345" }); + expect(validate({ flow_id: { a: 1 } })).toEqual({ flow_id: '{"a":1}' }); + expect(validate({ flow_id: ["a"] })).toEqual({ flow_id: '["a"]' }); + expect(validate({ flow_id: true })).toEqual({ flow_id: "true" }); expect("flow_id" in validate({})).toBe(false); }); +/** The whole return leg: what the verifier put in the URL is what sign-in carries back to it. */ +test("a numeric or JSON-shaped handle survives the round trip to the verifier", async () => { + const validate = Route.options.validateSearch as (s: unknown) => { + flow_id?: string; + }; + const numeric = await redirectFrom(validate({ flow_id: 12345 })); + expect(numeric.options.href).toBe("/api/plugins/arcade/verify?flow_id=12345"); + const shaped = await redirectFrom(validate({ flow_id: { a: 1 } })); + expect(shaped.options.href).toBe( + `/api/plugins/arcade/verify?flow_id=${encodeURIComponent('{"a":1}')}`, + ); +}); + function signedInContext() { const queryClient = new QueryClient(); queryClient.setQueryData(authKeys.currentUser(), { id: "u1" }); From 5b6a86034e27232d97ce608d228836fec1553387 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:28:20 +0200 Subject: [PATCH 147/232] fix(arcade): read an app's connection kind off the listing instead of writing consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listApps` wrote `connection: { kind: "consent" }` onto every toolkit in the Arcade catalogue, which is a claim about the vendor made without reading the vendor. The live listing says otherwise about real apps: `Apollo` — verbatim in `server/tests/fixtures/arcade-catalogue.json` — publishes NO `requirements.authorization` and one deployment-wide secret (`APOLLO_API_KEY`) instead, so it was announced to the rest of the system as an app somebody connects by clicking through a consent screen when no consent screen for it exists anywhere: Connect sent the person to a flow consent cannot complete, and `tools.authorize` was minted for an action that requires no authorization. The kind is now READ, per app, inside the fold that already pages the catalogue to its end — no second listing and no extra vendor call. An action carrying a `requirements.authorization` object makes its toolkit `consent`; a toolkit none of whose actions carries one asks the person connecting for nothing and is `no-auth`. Secrets deliberately do not enter that decision: a secret is set once for everybody by an operator in the Arcade dashboard (the standing `FIELDS_REFUSAL` sentence), so it is not something the person connecting can supply, and `grantStateOf` already answers `needs-operator` naming the keys. `fields` would draw a form collecting one person's answer and share it with everyone; `unsupported` would hide the app from the picker for good, including after the operator sets the secret and the app works. A `requirements` or `requirements.authorization` of a shape this deployment cannot read now REFUSES with a `BrokerRefusalError` naming the app, rather than being answered either way — a guessed `consent` is the defect above and a guessed `no-auth` hides the Connect button on an app that needs one. `null` and an absent field are not unreadable shapes: they are the vendor saying the action requires nothing, which `AirtableApi`'s `metadata: null` shows Arcade does say. Same-pattern audit of the whole file: `logo: null` and `categories: []` are the only other literals standing where a vendor fact would, and they are the vendor publishing neither — `ToolDefinition.Toolkit` carries `name`, `description` and `version` and nothing else, and a logo is filled in afterwards by `backfillBrokeredLogos`. A comment now records that measurement. Everything else hardcoded here (`grantIsLocal: false`, `ensureAuthConfig: "not-needed"`, `CLOSED_VERDICT`, `LIVE_CONNECTION_STATUS`) is a documented fact about Arcade's design rather than a per-app answer, and is unchanged. Call sites of the symbols added: - `shapeOf` (added, module-private): one call site, `requiresAuthorization`. - `requiresAuthorization` (added, module-private): one call site, the `listApps` fold, asked after the toolkit name so a refusal can name the app. - `connectionOf` (added, module-private): one call site, the finished-fold map in `listApps`. Not exported; `composio-adapter.ts` has its own function of that name and neither module imports the other. - `Tally` (module-private type inside `listApps`): now omits `connection` from `BrokerApp` and carries `authorizes: boolean`. Its only uses are the two in `listApps`. - `BrokerConnection` (type, imported from `./broker`): new import here; the type itself is unchanged, so every existing consumer is untouched. Tests: `server/tests/arcade-connection-kind-engine.test.ts` is new and drives the adapter against a real HTTP engine through `ARCADE_BASE_URL`, as `arcade-pagination-engine.test.ts` does — the suite's prototype stubs cannot be trusted to reproduce a `requirements` the SDK's own types forbid. Against the unfixed adapter 7 of its 9 tests failed (Apollo, a no-requirements toolkit and a `null` requirements all answered `consent`; none of the four unreadable shapes refused); all 9 pass now. `arcade-adapter.test.ts`'s "no app resolves to a fields connection" gained `no-auth` to its list, because its fixture publishes no `requirements` at all — `fields`, which that test is about, is still unreachable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 104 +++++++++- server/tests/arcade-adapter.test.ts | 9 +- .../arcade-connection-kind-engine.test.ts | 182 ++++++++++++++++++ 3 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 server/tests/arcade-connection-kind-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 64cb081c0..f8b887aea 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -4,6 +4,7 @@ import { BrokerRefusalError, flagOf, type BrokerApp, + type BrokerConnection, type ConnectedAppBroker, type GrantState, } from "./broker"; @@ -222,6 +223,84 @@ function scopesOf(item: unknown): string[] { : []; } +/** + * WHAT ARRIVED WHERE A SHAPE BELONGED, named without quoting it. + * + * A listing row carries whatever the vendor chose to put on it, and a refusal from here is read off + * an admin page, so the SHAPE is the part that is both safe to say and the part that helps — + * `sent` in `composio-adapter.ts` says this at length and this is the same judgement, kept local + * because that file is the other vendor's door and neither imports the other. + */ +function shapeOf(value: unknown): string { + if (Array.isArray(value)) return "a list"; + return `a ${typeof value}`; +} + +/** + * Whether ARCADE SAYS this action needs a person to authorize it — read, never assumed. + * + * THIS REPLACES A HARDCODED `{ kind: "consent" }` THAT WAS A CLAIM ABOUT THE VENDOR MADE WITHOUT + * READING THE VENDOR. Every toolkit in the catalogue was announced to the rest of this deployment + * as an app somebody connects by clicking through a consent screen, and the live listing says + * otherwise about real apps: `Apollo` publishes NO `requirements.authorization` and one + * deployment-wide secret (`APOLLO_API_KEY`) instead — verbatim in `fixtures/arcade-catalogue.json` + * — so Connect sent somebody to a consent flow for a connection consent cannot complete, and + * `tools.authorize` was minted for an action that requires no authorization at all. + * + * AN AUTHORIZATION BLOCK IS THE WHOLE OF THE QUESTION, AND SECRETS ARE NOT PART OF IT. The two are + * different people's work and {@link grantStateOf} already keeps them apart for the same reason: + * a secret is set ONCE FOR EVERYBODY by an operator in the Arcade dashboard — {@link FIELDS_REFUSAL} + * is this deployment's standing sentence about that — so it is not something the person connecting + * can supply, and an app that asks only for one asks THEM for nothing. It is therefore `no-auth` + * here and `needs-operator` on the grants screen, which is the channel that already names the keys. + * Calling it `fields` would draw a form collecting one person's answer and share it with everyone; + * calling it `unsupported` would hide the app from the picker for good, including after the + * operator sets the secret and the app works perfectly. + * + * ANY ONE ACTION'S AUTHORIZATION MAKES THE TOOLKIT A CONSENT APP, because the question this answers + * is "how would somebody connect this app", and a toolkit with one authorized action has a consent + * flow that is how they reach it. Arcade grants per action, so the grants screen is where an action + * that needs nothing is told apart from one that does. + * + * AND A SHAPE THIS DEPLOYMENT CANNOT READ REFUSES RATHER THAN ANSWERING EITHER WAY. Both wrong + * answers are silent: a guessed `consent` is the defect above, and a guessed `no-auth` records an + * app as needing no connection, which hides the Connect button on an app that does. `routes.ts` + * holds the catalogue for the picker and the enable path both, so one refusal an operator can act + * on is worth more than several hundred rows, one of which is a guess — `connectionOf` in + * `composio-adapter.ts` gives that reasoning in full. `null` and an absent field are NOT unreadable + * shapes: they are the vendor saying this action requires nothing, which `AirtableApi`'s + * `metadata: null` shows Arcade does say. + */ +function requiresAuthorization(item: unknown, toolkit: string): boolean { + const requirements = record(item)?.requirements; + if (requirements === undefined || requirements === null) return false; + if (typeof requirements !== "object" || Array.isArray(requirements)) { + throw new BrokerRefusalError( + `Arcade sent ${shapeOf(requirements)} where ${toolkit}'s requirements belong, and that field is what says whether somebody has to authorize this app at all. A shape this deployment cannot read is not the vendor saying "nothing to authorize", so it is not read as one — the catalogue stops here rather than offer ${toolkit} under a flow nobody established it has. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`, + ); + } + const authorization = (requirements as Record).authorization; + if (authorization === undefined || authorization === null) return false; + if (typeof authorization !== "object" || Array.isArray(authorization)) { + throw new BrokerRefusalError( + `Arcade sent ${shapeOf(authorization)} where ${toolkit}'s authorization requirement belongs, and this deployment reads that object to decide whether connecting ${toolkit} means sending somebody to a consent screen. A shape this deployment cannot read decides neither way, so the catalogue stops here rather than guess. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`, + ); + } + return true; +} + +/** + * The flow an app gets, from the one fact {@link requiresAuthorization} measured over its actions. + * + * TWO OF THE FIVE KINDS AND NOT THE OTHER THREE, deliberately. `fields` and `self-registering` are + * Composio's shapes: Arcade has no per-person field entry ({@link FIELDS_REFUSAL}) and registers no + * client at consent time. `unsupported` is a verdict about an app this deployment cannot drive, and + * both answers here are apps it drives — the unreadable case REFUSES rather than landing here. + */ +function connectionOf(authorizes: boolean): BrokerConnection { + return authorizes ? { kind: "consent" } : { kind: "no-auth" }; +} + /** * Every toolkit Arcade publishes, with `actionCount` taken from the FULL PAGED tool listing. * @@ -249,11 +328,17 @@ async function listApps(client: Arcade): Promise { */ type Tally = Omit< BrokerApp, - "classifiedCount" | "permissionCount" | "scopedActionCount" + "classifiedCount" | "connection" | "permissionCount" | "scopedActionCount" > & { classifiedCount: number; scopedActionCount: number; permissions: Set; + /** + * Whether any action folded so far carries an authorization requirement — the ONE fact + * {@link connectionOf} turns into this app's flow, and a fold rather than a literal because the + * listing is per ACTION and the flow is per TOOLKIT. + */ + authorizes: boolean; }; const counts = new Map(); for await (const item of pagedItems({ @@ -276,6 +361,9 @@ async function listApps(client: Arcade): Promise { if (!name) continue; const classified = isClassified(record(item)?.metadata); const scopes = scopesOf(item); + // READ OFF THIS ACTION, never written down for the catalogue; see {@link requiresAuthorization}. + // Asked AFTER the name, so a refusal can say which app it is about. + const authorizes = requiresAuthorization(item, name); const standing = counts.get(name) ?? ({ @@ -284,16 +372,22 @@ async function listApps(client: Arcade): Promise { // An absent description IS an empty one here: the field is a label with a blank default, // not an identity, so nothing is shadowed by the two reading alike. description: textOf(toolkit?.description) ?? "", + // NEITHER IS A STAND-IN FOR SOMETHING UNREAD: a tool's `toolkit` object carries `name`, + // `description` and `version` and nothing else — the SDK's `ToolDefinition.Toolkit` says + // so and the live listing agrees — so these two are Arcade publishing no logo and no + // category, which is what `BrokerApp.logo` is nullable for. A logo, where one exists, is + // filled in afterwards by `backfillBrokeredLogos` from outside the vendor's listing. logo: null, categories: [], actionCount: 0, classifiedCount: 0, scopedActionCount: 0, permissions: new Set(), - connection: { kind: "consent" }, + authorizes: false, } satisfies Tally); standing.actionCount += 1; if (classified) standing.classifiedCount += 1; + if (authorizes) standing.authorizes = true; if (scopes.length > 0) { standing.scopedActionCount += 1; // SORTED, THEN JOINED. The other order makes one permission into two the first time the @@ -304,9 +398,13 @@ async function listApps(client: Arcade): Promise { } // `permissionCount` IS READ OFF THE FINISHED SET, never kept per page: a size taken mid-listing // is a count of the sets seen so far, which is a different number on every page but the last. - return [...counts.values()].map(({ permissions, ...app }) => ({ + // `connection` IS READ OFF THE FINISHED FOLD for `permissionCount`'s reason: a kind decided + // mid-listing is a verdict about the actions seen so far, and the action that carries the + // authorization may be on the last page. + return [...counts.values()].map(({ permissions, authorizes, ...app }) => ({ ...app, permissionCount: permissions.size, + connection: connectionOf(authorizes), })); } diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index 24cf556a7..ef06e3016 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -271,7 +271,14 @@ describe("createArcadeClient", () => { ); const apps = await createArcadeClient("never-dialled").broker.listApps(); expect(apps.length).toBeGreaterThan(0); + // `no-auth` is in the list because `toolOf` publishes no `requirements` at all, which is Arcade + // saying this action needs authorizing by nobody — the kind is READ per app now rather than + // written once for the catalogue, and `arcade-connection-kind-engine.test.ts` is where each + // published shape is held to its answer. `fields` is the one this test is about and it is still + // unreachable: Arcade has no per-person field entry. for (const app of apps) - expect(["consent", "unsupported"]).toContain(app.connection.kind); + expect(["consent", "no-auth", "unsupported"]).toContain( + app.connection.kind, + ); }); }); diff --git a/server/tests/arcade-connection-kind-engine.test.ts b/server/tests/arcade-connection-kind-engine.test.ts new file mode 100644 index 000000000..a1d1d133f --- /dev/null +++ b/server/tests/arcade-connection-kind-engine.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { BrokerApp } from "../src/plugins/broker"; + +/** + * WHICH FLOW AN ARCADE APP GETS IS READ OFF THE LISTING, AND THIS IS THE FILE THAT WATCHES IT. + * + * `listApps` used to write `connection: { kind: "consent" }` onto every toolkit in the catalogue, + * which is a claim about the vendor made without reading the vendor. `Apollo` — verbatim below, + * from `fixtures/arcade-catalogue.json` — publishes NO `requirements.authorization` at all and one + * deployment-wide secret instead, so it was announced as an app somebody connects by clicking + * through a consent screen when there is no consent screen for it anywhere: pressing Connect mints + * an authorization for a tool that requires none. + * + * SERVED OVER A REAL SOCKET THROUGH `ARCADE_BASE_URL`, as `arcade-pagination-engine.test.ts` does + * and for its reason: the adapter suite's prototype stubs hand the adapter whatever shape the test + * felt like, and the shapes this file is about — a `requirements` the SDK's own types forbid — are + * exactly the ones a stub cannot be trusted to reproduce. + */ + +type Engine = { port: number; stop: () => void }; + +const toolkitOf = (name: string) => ({ + name, + description: `Arcade tools designed for LLMs to interact with ${name}`, + version: "1.0.0", +}); + +/** Verbatim from the live listing: an oauth2 authorization, which IS a consent flow. */ +const OAUTH_REQUIREMENTS = { + met: true, + authorization: { + id: "arcade-asana", + provider_id: "asana", + provider_type: "oauth2", + oauth2: { scopes: ["default"] }, + status: "active", + }, +}; + +/** Verbatim from the live listing: a deployment-wide secret and NO authorization whatsoever. */ +const SECRET_REQUIREMENTS = { + met: false, + secrets: [ + { + key: "APOLLO_API_KEY", + met: false, + status_reason: "Secret APOLLO_API_KEY not found", + }, + ], +}; + +const toolOf = (toolkit: string, n: number, extra: object = {}) => ({ + qualified_name: `${toolkit}.Action${n}`, + name: `Action${n}`, + fully_qualified_name: `${toolkit}.Action${n}@1.0.0`, + toolkit: toolkitOf(toolkit), + input: { parameters: [] }, + ...extra, +}); + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(tools: unknown[]): Engine { + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname !== "/v1/tools") { + return new Response("not found", { status: 404 }); + } + const offset = Number(url.searchParams.get("offset") ?? "0"); + const limit = Number(url.searchParams.get("limit") ?? "100"); + const page = tools.slice(offset, offset + limit); + return Response.json({ + items: page, + offset, + limit, + page_count: page.length, + total_count: tools.length, + }); + }, + }); + running = { port: server.port, stop: () => server.stop(true) }; + process.env.ARCADE_BASE_URL = `http://localhost:${server.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +const appOf = (apps: BrokerApp[], slug: string): BrokerApp => { + const app = apps.find((a) => a.slug === slug); + if (!app) throw new Error(`no app ${slug} in the directory`); + return app; +}; + +const listed = () => createArcadeClient("never-dialled").broker.listApps(); + +describe("the connection kind is read off what Arcade publishes", () => { + test("an app Arcade publishes an oauth2 authorization for is a consent app", async () => { + engine([toolOf("Asana", 0, { requirements: OAUTH_REQUIREMENTS })]); + expect(appOf(await listed(), "Asana").connection).toEqual({ + kind: "consent", + }); + }); + + test("an app that asks only for a deployment-wide secret is NOT a consent app", async () => { + // Apollo's live shape: a secret an operator sets once for everybody, and no authorization at + // all. There is no consent screen to send anybody to, so announcing one is a wrong answer. + engine([toolOf("Apollo", 0, { requirements: SECRET_REQUIREMENTS })]); + expect(appOf(await listed(), "Apollo").connection).toEqual({ + kind: "no-auth", + }); + }); + + test("an app that publishes no requirements at all asks nobody for anything", async () => { + engine([toolOf("Hackernews", 0)]); + expect(appOf(await listed(), "Hackernews").connection).toEqual({ + kind: "no-auth", + }); + }); + + test("one action's authorization makes the whole toolkit a consent app", async () => { + // A toolkit whose actions disagree still HAS a consent flow, and that flow is how somebody + // reaches the actions that need it. + engine([ + toolOf("Mixed", 0, { requirements: SECRET_REQUIREMENTS }), + toolOf("Mixed", 1, { requirements: OAUTH_REQUIREMENTS }), + ]); + expect(appOf(await listed(), "Mixed").connection).toEqual({ + kind: "consent", + }); + }); + + test("the kinds are read per app, not written once for the catalogue", async () => { + engine([ + toolOf("Asana", 0, { requirements: OAUTH_REQUIREMENTS }), + toolOf("Apollo", 0, { requirements: SECRET_REQUIREMENTS }), + toolOf("Hackernews", 0), + ]); + const apps = await listed(); + expect( + Object.fromEntries(apps.map((a) => [a.slug, a.connection.kind])), + ).toEqual({ Asana: "consent", Apollo: "no-auth", Hackernews: "no-auth" }); + }); +}); + +describe("a requirements shape this deployment cannot read is refused, never guessed", () => { + test("requirements that are not an object refuse, naming the app", async () => { + engine([toolOf("Apollo", 0, { requirements: "yes" })]); + await expect(listed()).rejects.toThrow( + /Arcade sent a string where Apollo's requirements belong/, + ); + }); + + test("an authorization that is not an object refuses, naming the app", async () => { + engine([toolOf("Asana", 0, { requirements: { authorization: "oauth2" } })]); + await expect(listed()).rejects.toThrow( + /Arcade sent a string where Asana's authorization requirement belongs/, + ); + }); + + test("a list where requirements belong is refused as a list", async () => { + engine([toolOf("Asana", 0, { requirements: [] })]); + await expect(listed()).rejects.toThrow( + /Arcade sent a list where Asana's requirements belong/, + ); + }); + + test("a null requirements is the vendor's silence and not a bad shape", async () => { + engine([toolOf("Hackernews", 0, { requirements: null })]); + expect(appOf(await listed(), "Hackernews").connection).toEqual({ + kind: "no-auth", + }); + }); +}); From f527d4313211ae6147cd91aa8f659be9ebb2985a Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:28:47 +0200 Subject: [PATCH 148/232] fix(arcade): bound every outbound broker call, not just how many times we ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D5. `settledStatus` bounded the poll COUNT and nothing else. Five unbounded waits are unbounded: a vendor that accepts the connection and then never writes a byte held the socket, the handler and the browser open forever — the exact case the function's own docblock claimed to prevent, because the loop condition after the first `await flow.status(id)` was never reached. `withVendorDeadline` is the fix and the invariant: EVERY outbound broker call in `routes.ts` now runs under a deadline, and the seams that can carry an `AbortSignal` get one so the abandonment reaches the wire rather than leaving this deployment holding a socket nobody is waiting on. A poll that spends its own bound THROWS rather than counting — a vendor that will not answer in two seconds is not answering — and the caller's existing `vendor-unavailable` refusal leaves the attempt row standing exactly as the exhausted-polls refusal does. Nothing of the vendor's travels with it: the timeout error names only this deployment's own bound, carries no `cause`, and is neither authored by `brokerSentence` nor matched by `vendorSentence`, so each call site answers with its own generic sentence (D31 gate run). The comment's arithmetic was wrong too (five polls was ~4s, not ~5s, because the pause is BETWEEN polls — four of them). FIXED THE CLAIM, not the code: adding a fifth pause would lengthen the wait for no reason. The docblocks now state the true worst case, which the per-call bound makes exact — five polls at 2s with four 1s pauses, at most 14 seconds. Same-pattern audit of the whole of `routes.ts`. Every outbound vendor call is now bounded; `settledStatus` was the only bounded-retry loop (the other two `for` loops iterate local objects): :1136 listApps (directory) :2372 grantsFor :1245 listApps (enable) :2784 authorize (per-action mint) :1719 connectionFields :3128 confirmReturn (return leg) :2096 authorize (connect) :3243 revoke (D28 withdrawal) :336 status x5 (settledStatus) :3275 authProviderOf The mint's deadline lands in that route's UNCERTAIN branch (not authored, not an SDK error), so a flow the vendor may have created behind the deadline leaves its attempt standing rather than being discarded beside a second one. Call sites for the resignatured symbols: AuthorizationFlow.status(id) -> status(id, options?: {signal?}) impl: server/src/plugins/arcade-adapter.ts:891 (passes signal to the SDK) caller: server/src/plugins/routes.ts:336 (settledStatus) stubs: arcade-verify, arcade-grants-route, arcade-authorize-route, arcade-authorize-cancel, arcade-siblings, arcade-sibling-collateral, remove-server-foreign-provenance, arcade-completion-transaction, composio-grants-for, arcade-live — all unchanged; the parameter is optional, so a stub that ignores it still satisfies the seam. AuthorizationFlow.confirmReturn(q) -> confirmReturn(q, options?: {signal?}) impl: server/src/plugins/arcade-adapter.ts:905 caller: server/src/plugins/routes.ts:3128 stubs: same files, unchanged, same reason. Added (module-private, no external call sites): VendorTimeoutError, withVendorDeadline, VENDOR_CALL_TIMEOUT_MS, STATUS_CALL_TIMEOUT_MS. STATUS_POLLS and STATUS_PAUSE_MS keep their names and values. Red-green: `arcade-verify.test.ts` gains "a vendor that ACCEPTS AND NEVER ANSWERS still completes, refusing" — a status that never settles and never rejects, which is what a hung socket looks like and which the throwing case beside it does not cover. Against unfixed code it failed on its own watchdog after 20s; fixed, it refuses in 2.5s. The watchdog ANSWERS rather than throwing, so a regression fails with a diagnosis instead of hanging the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 28 ++-- server/src/plugins/broker.ts | 19 ++- server/src/plugins/routes.ts | 236 ++++++++++++++++++++------- server/tests/arcade-verify.test.ts | 50 ++++++ 4 files changed, 262 insertions(+), 71 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 64cb081c0..3dc0a9647 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1,10 +1,10 @@ import Arcade, { APIError } from "@arcadeai/arcadejs"; -import { classify, type ArcadeActions, type ArcadeFailure } from "./arcade"; +import { type ArcadeActions, type ArcadeFailure, classify } from "./arcade"; import { - BrokerRefusalError, - flagOf, type BrokerApp, + BrokerRefusalError, type ConnectedAppBroker, + flagOf, type GrantState, } from "./broker"; @@ -888,8 +888,13 @@ export function createArcadeClient(apiKey: string): { * what is not returned cannot be logged, attached as a `cause`, or written into an audit * payload by code that never sees it. Do not widen this, and do not add `url`. */ - status: async (id) => { - const answer = await client.auth.status({ id }); + status: async (id, options) => { + // The caller's deadline reaches the wire: an abandoned poll closes its socket rather than + // leaving this deployment holding one open against a vendor nobody is waiting on. + const answer = await client.auth.status( + { id }, + { signal: options?.signal }, + ); const scopes = answer.scopes; return { status: textOf(answer.status) ?? "unknown", @@ -902,12 +907,15 @@ export function createArcadeClient(apiKey: string): { // `handle` is opaque to the seam; `flow_id` is Arcade's word for it and stops in this file. // A `user_mismatch` answer is an ANSWER, so it surfaces as `{ mismatch: true }`; any other // failure is an outage or a bug and is rethrown untouched. - confirmReturn: async ({ handle, vendorUserId }) => { + confirmReturn: async ({ handle, vendorUserId }, options) => { try { - const answer = await client.auth.confirmUser({ - flow_id: handle, - user_id: vendorUserId, - }); + const answer = await client.auth.confirmUser( + { + flow_id: handle, + user_id: vendorUserId, + }, + { signal: options?.signal }, + ); return { authorizationId: answer.auth_id }; } catch (error) { if (normalizeArcadeError(error)?.name === "user_mismatch") { diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index 32922d856..e86c8e4ac 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -427,9 +427,17 @@ export type AuthorizationFlow = { * NARROWED TO THESE THREE FIELDS AND NOTHING ELSE. A provider's own status reply carries the live * access token it issued and the person's full profile beside it, and neither is something a seam * this deployment reads in routes and stores should be able to hand on by accident. + * + * `signal` IS OPTIONAL ON THE SEAM AND HONOURED BY THE ADAPTER. The caller polls this on a leg + * where a browser is held open, and a poll COUNT bounds nothing while each call is unbounded — + * so the caller gives up on its own schedule and says so here, and the adapter hands the signal + * to the SDK's request options rather than leaving a socket open against a vendor nobody is + * waiting on any more. A stub that ignores it is behaving correctly: an abandoned call is still + * abandoned, the signal only makes the abandonment reach the wire. */ status( id: string, + options?: { signal?: AbortSignal }, ): Promise<{ status: string; userId: string | null; scopes: string[] }>; /** * Match somebody coming back from the provider's screen to the authorization they left through. @@ -443,10 +451,13 @@ export type AuthorizationFlow = { * ANSWER: it is exactly the case a return leg has to be able to refuse quietly, and a caller * cannot tell it from an outage if both arrive as exceptions. */ - confirmReturn(q: { - handle: string; - vendorUserId: string; - }): Promise<{ authorizationId: string } | { mismatch: true }>; + confirmReturn( + q: { + handle: string; + vendorUserId: string; + }, + options?: { signal?: AbortSignal }, + ): Promise<{ authorizationId: string } | { mismatch: true }>; }; /** diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index d5f8ee5ec..6be4b2c0f 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -221,17 +221,112 @@ function sameAccount(left: AccountRef, right: AccountRef): boolean { return right.holder === "deployment"; } -/** At most five polls over about five seconds. NEVER the SDK's unbounded `waitForCompletion`. */ +/** + * A BROKER CALL THAT DID NOT ANSWER INSIDE ITS BOUND, AND CARRYING NOTHING OF THE VENDOR'S (D31). + * + * The message names this deployment's own bound and the thrown object has no `cause`, deliberately: + * a call that times out on the return leg is a call whose answer would have carried a LIVE provider + * access token and a full profile, and the one thing certain about a half-read answer is that + * nobody has narrowed it yet. So there is nothing here to attach, log or quote — which is the same + * promise {@link brokerRefusal} makes about the vendor's own errors, kept by having no vendor + * material to begin with. + * + * It is an ordinary `Error` to every reader of it: `brokerSentence` does not author it and + * `vendorSentence` finds no nested body, so every call site below answers it with its OWN generic + * sentence and a 502 — "we asked and got nothing back", which is exactly what happened. + * + * NOT EXPORTED, because nothing outside this file should be branching on it: every exit it can + * reach is already one of this file's own refusals, and a caller matching on the class would be + * re-deciding what the catch blocks here have decided. + */ +class VendorTimeoutError extends Error { + constructor(afterMs: number) { + super(`The broker did not answer within ${afterMs}ms.`); + this.name = "VendorTimeoutError"; + } +} + +/** + * How long ONE outbound call to the broker may take before this deployment stops waiting on it. + * + * EVERY CALL OUT OF THIS FILE GOES THROUGH {@link withVendorDeadline}, and that is the invariant + * rather than the number. A bound on how MANY times something is attempted is not a bound at all + * while each attempt is unbounded: a vendor that accepts the connection and then never writes a + * byte holds the socket, the handler and the browser open for as long as it cares to, and no retry + * limit, poll count or loop condition is ever reached because the first wait never ends. + */ +const VENDOR_CALL_TIMEOUT_MS = 10_000; + +/** + * The same bound on ONE return-leg status poll, and tighter because a browser is held open on it. + * + * Five polls at two seconds with four one-second pauses is a worst case of FOURTEEN seconds for the + * whole wait, and that arithmetic is the point of stating both numbers here. + */ +const STATUS_CALL_TIMEOUT_MS = 2_000; + +/** + * At most five polls, pausing BETWEEN them — so four pauses, not five. NEVER the SDK's unbounded + * `waitForCompletion`. + */ const STATUS_POLLS = 5; const STATUS_PAUSE_MS = 1000; /** - * Where one authorization has got to, waited for with a BOUND. + * One outbound vendor call under a deadline, with an `AbortSignal` for the seams that take one. + * + * THE SIGNAL IS THE HALF THAT MATTERS UPSTREAM AND THE RACE IS THE HALF THAT MATTERS HERE. The race + * is what lets the handler answer and the browser go; aborting is what stops this deployment from + * holding a socket open against a vendor it has already given up on, and the seams that can carry + * a signal take it (see {@link AuthorizationFlow}). A seam that cannot is still bounded by the + * race — a timeout is a timeout to the person waiting either way — and its call is simply + * abandoned rather than cancelled. + * + * THE TIMER IS CLEARED ON BOTH OUTCOMES. A pending timer on a call that answered in time would keep + * an event loop alive for the rest of the bound, which in a test run is a suite that will not exit + * and in a deployment is a handler that has returned still holding a reason not to. + * + * NOTHING IS RETRIED HERE. A deadline that retried would be the very shape this fix exists to + * remove, one layer up. + */ +function withVendorDeadline( + call: (signal: AbortSignal) => Promise, + afterMs: number = VENDOR_CALL_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new VendorTimeoutError(afterMs)); + }, afterMs); + }); + /* + * `Promise.race` subscribes to both, so a call that answers AFTER the deadline has its rejection + * observed here and never surfaces as an unhandled one. Its answer is dropped unread, which is + * also the D31-safe thing to do with it. + */ + return Promise.race([call(controller.signal), deadline]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +/** + * Where one authorization has got to, waited for with a BOUND ON THE WHOLE WAIT AND ON EACH CALL. + * + * `pending` is the only state worth asking again about, and five polls at two seconds each with a + * one-second pause between them — at most fourteen seconds, and the pauses are BETWEEN the polls, + * so four of them and not five — is the whole of the patience this has. A timeout is neither a + * success nor a failure, so what comes back is the last answer read and the caller decides; nothing + * here writes, retries forever or blocks a browser on a vendor that has stopped answering. * - * `pending` is the only state worth asking again about, and asking five times over five seconds is - * the whole of the patience this has. A timeout is neither a success nor a failure, so what comes - * back is the last answer read and the caller decides; nothing here writes, retries forever or - * blocks a browser on a vendor that has stopped answering. + * THE POLL COUNT ALONE NEVER BOUNDED ANYTHING. It bounded how many times `flow.status` was CALLED, + * while each call waited as long as the vendor liked — so a vendor that accepted the connection and + * never answered held this request open forever, which is the one case the paragraph above claims + * to prevent. {@link withVendorDeadline} is what makes the claim true, and a poll that spends its + * own bound THROWS rather than counting: a vendor that will not answer in two seconds is a vendor + * that is not answering, and the caller's `vendor-unavailable` refusal leaves the attempt row + * standing exactly as the exhausted-polls refusal does. * * WHAT COMES BACK IS THE SEAM'S THREE FIELDS AND NOTHING MORE (D31). The vendor's own status reply * carries a live provider access token and the person's full profile; the adapter drops them, and @@ -241,14 +336,19 @@ async function settledStatus( flow: AuthorizationFlow, id: string, ): Promise>> { - let answer = await flow.status(id); + const poll = () => + withVendorDeadline( + (signal) => flow.status(id, { signal }), + STATUS_CALL_TIMEOUT_MS, + ); + let answer = await poll(); for ( - let poll = 1; - poll < STATUS_POLLS && answer.status === "pending"; - poll += 1 + let polls = 1; + polls < STATUS_POLLS && answer.status === "pending"; + polls += 1 ) { await new Promise((resolve) => setTimeout(resolve, STATUS_PAUSE_MS)); - answer = await flow.status(id); + answer = await poll(); } return answer; } @@ -1037,7 +1137,7 @@ export function createPluginRoutes( let directory: BrokerApp[]; try { - directory = await composio.broker.listApps(); + directory = await withVendorDeadline(() => composio.broker.listApps()); } catch (error) { // The vendor's own sentence where there is one, because "invalid api key" is the diagnosis // and a 500 is not. See {@link brokerRefusal} for what is kept out of the answer. @@ -1146,7 +1246,7 @@ export function createPluginRoutes( let directory: BrokerApp[] = []; if (slug) { try { - directory = await composio.broker.listApps(); + directory = await withVendorDeadline(() => composio.broker.listApps()); } catch (error) { /* * The same failure the directory route answers, in the same words, because it is the same @@ -1620,10 +1720,12 @@ export function createPluginRoutes( */ let published: BrokerField[]; try { - published = await composio.broker.connectionFields({ - toolkit, - authScheme, - }); + published = await withVendorDeadline(() => + composio.broker.connectionFields({ + toolkit, + authScheme, + }), + ); } catch (error) { const refusal = brokerRefusal( error, @@ -1995,28 +2097,30 @@ export function createPluginRoutes( */ let redirectUrl: string; try { - ({ redirectUrl } = await composio.broker.authorize({ - account, - toolkit, - /* - * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. - * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` - * entry — and nothing between there and Composio has ever looked at it, so - * `openbot.example.com` with the scheme left off builds a callback that is not a - * callback. That failure lands after somebody has consented, on the vendor's page, where - * this deployment cannot tell them anything; the guard moves it to before the link is - * minted, where nobody has spent a consent on it yet. - * - * WHO IS READING DECIDES WHAT IS SAID, AND HERE THAT IS A PERSON. This route is - * `requireUser`: whoever pressed Connect gets the refusal's own sentence, which names no - * vendor and no variable because they can act on neither. The operator's half of it is - * logged below instead, where an operator looks and a person does not. - */ - returnUrl: brokerReturnUrl( - connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), - activeProvider, - ), - })); + ({ redirectUrl } = await withVendorDeadline(() => + composio.broker.authorize({ + account, + toolkit, + /* + * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. + * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` + * entry — and nothing between there and Composio has ever looked at it, so + * `openbot.example.com` with the scheme left off builds a callback that is not a + * callback. That failure lands after somebody has consented, on the vendor's page, where + * this deployment cannot tell them anything; the guard moves it to before the link is + * minted, where nobody has spent a consent on it yet. + * + * WHO IS READING DECIDES WHAT IS SAID, AND HERE THAT IS A PERSON. This route is + * `requireUser`: whoever pressed Connect gets the refusal's own sentence, which names no + * vendor and no variable because they can act on neither. The operator's half of it is + * logged below instead, where an operator looks and a person does not. + */ + returnUrl: brokerReturnUrl( + connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + activeProvider, + ), + }), + )); } catch (error) { logReturnUrlRefusal(error, serverId); const refusal = brokerRefusal( @@ -2269,7 +2373,9 @@ export function createPluginRoutes( } let actions: GrantState[]; try { - actions = await broker.grantsFor({ toolkit, account }); + actions = await withVendorDeadline(() => + broker.grantsFor({ toolkit, account }), + ); } catch { actions = [ { @@ -2679,12 +2785,14 @@ export function createPluginRoutes( let minted: Awaited>; try { - minted = await composio.broker.authorize({ - account, - toolkit, - action, - returnUrl, - }); + minted = await withVendorDeadline(() => + composio.broker.authorize({ + account, + toolkit, + action, + returnUrl, + }), + ); } catch (error) { /* * WHAT A FAILED MINT DOES TO ITS ATTEMPT. THREE OUTCOMES, KEYED ON WHAT WAS ANSWERED. @@ -2708,6 +2816,11 @@ export function createPluginRoutes( * for a mint that answered SUCCESSFULLY without an `ar_...`: Arcade very likely did create a * flow. Do not tidy this case into the definite branch. * + * AND {@link VendorTimeoutError} LANDS IN THAT SAME UNCERTAIN BRANCH, which is why the mint's + * deadline is safe to add: it is neither authored nor an SDK error, so it is read as "we + * stopped waiting and do not know", the attempt is left standing, and a flow the vendor may + * have created behind the deadline is not orphaned beside a second one. + * * NOTHING FROM THE VENDOR'S ANSWER IS LOGGED, AUDITED OR RETURNED: its body can carry the * authorization url, a bearer capability. */ @@ -3016,10 +3129,17 @@ export function createPluginRoutes( */ let confirmed: Awaited>; try { - confirmed = await flow.confirmReturn({ - handle: flowId, - vendorUserId: attempt.vendorUserId, - }); + confirmed = await withVendorDeadline( + (signal) => + flow.confirmReturn( + { + handle: flowId, + vendorUserId: attempt.vendorUserId, + }, + { signal }, + ), + STATUS_CALL_TIMEOUT_MS, + ); } catch { /* * NOTHING FROM THE VENDOR'S ANSWER TRAVELS WITH THIS — not its sentence, not its body, not @@ -3124,10 +3244,12 @@ export function createPluginRoutes( * not work out what to withdraw". Over a possibly-live grant the safe reading of that * ambiguity is "we do not know": it takes the not-withdrawn branch, as a throw does. */ - withdrawn = await composio.broker.revoke({ - account: retired, - toolkit: retiredApp, - }); + withdrawn = await withVendorDeadline(() => + composio.broker.revoke({ + account: retired, + toolkit: retiredApp, + }), + ); } catch { // Nothing from the vendor's answer travels with this, here as everywhere on this leg. withdrawn = false; @@ -3154,9 +3276,9 @@ export function createPluginRoutes( * live. It is never read back out of a status reply, which D31's narrowing makes * structurally impossible anyway. */ - const authProvider = await composio.broker - .authProviderOf(fresh.toolkit) - .catch(() => null); + const authProvider = await withVendorDeadline(() => + composio.broker.authProviderOf(fresh.toolkit), + ).catch(() => null); await store.completeAuthorizationAttempt({ actorId, authId, diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 1ccf0eac9..1d2c6641b 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -782,6 +782,56 @@ describe("what the authorization's own status decides", () => { expect(row?.outcome).toBeNull(); await expectNothingWritten(); }); + + /** + * D5: A VENDOR THAT ACCEPTS AND NEVER ANSWERS. The one above throws, which every bounded loop + * survives; this one does neither — it takes the call and goes quiet, which is what a hung socket + * actually looks like. + * + * A BOUND ON THE POLL COUNT IS NOT A BOUND. Five unbounded waits are unbounded, so the loop + * condition is never reached, the handler never returns, and the browser is held open for as long + * as the vendor cares to hold it — the exact case `settledStatus`'s own docblock claims to + * prevent. The assertion is therefore not about a status code: it is that the request COMPLETES. + * + * AND THE TEST CANNOT HANG THE SUITE EVEN WHEN THE FIX IS GONE. The request is raced against this + * case's own watchdog and the watchdog ANSWERS rather than throwing, so a regression fails here + * with "it never answered" instead of running the suite into its timeout with no diagnosis. The + * watchdog is longer than the route's worst case (five polls at two seconds with four one-second + * pauses) and shorter than this case's own timeout, which is the whole of what it has to be. + */ + test("a vendor that ACCEPTS AND NEVER ANSWERS still completes, refusing", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + // Never settles and never rejects: the vendor is holding the connection open. + statusAnswer = () => new Promise(() => {}); + + let watchdog: ReturnType | undefined; + const hung = new Promise<"never answered">((resolve) => { + watchdog = setTimeout(() => resolve("never answered"), 20_000); + }); + const answered = await Promise.race([ + verify(ACTOR, "?flow_id=flow-abc"), + hung, + ]).finally(() => { + if (watchdog !== undefined) clearTimeout(watchdog); + }); + + expect(answered).not.toBe("never answered"); + const response = answered as Response; + await expectRefusal(response, "vendor-unavailable"); + // The suite-wide promise: nothing of the vendor's rides out on any header or in the body. + expect(mentionsOf(await asDelivered(response), TOKEN)).toEqual([]); + + /* + * AND THE ROW IS LEFT STANDING, as it is for every other "we could not read the status" exit. + * A consent that may well have finished at the vendor must still be finishable by somebody who + * comes back to the link. + */ + const row = await attemptRow(); + expect(row?.completedAt).toBeNull(); + expect(row?.outcome).toBeNull(); + expect(row?.authId).toBe(MINTED); + await expectNothingWritten(); + }, 30_000); }); /** From ceefa4dda3552ab64ea4944fe91827f9a409fabe Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:30:55 +0200 Subject: [PATCH 149/232] test(server): close the test suite's egress so no case can dial a vendor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `server/tests/composio-broker-seam.test.ts` reached `https://backend.composio.dev/api/v3.1/auth_configs` on every run, measured rather than reasoned: a preload that wrapped `globalThis.fetch` and recorded the target printed that URL with `@composio/client`'s `fetchWithTimeout` on the stack. The file reads as if it stubs. It assigns `globalThis.fetch` around the call and restores it in a `finally`. What it does not do is assign it BEFORE constructing the vendor client: `@composio/client` binds its transport once, in its constructor (`this.fetch = options.fetch ?? Shims.getDefaultFetch()`, `client.mjs:122`), so the client held the real `fetch` and the stub governed nothing. The case passed anyway, because Composio's refusal of the key `never-dialled` also does not contain the word `action`. That is the shape of the defect, and it is why the fix is not only in that file: a per-file stub can be present, correct-looking and inert, and nothing in review says so. `server/scripts/test-network-guard.ts` runs from the preload, before any test file is imported and therefore before any client can capture a transport, and refuses every HTTP request — `fetch`, `node:http`, `node:https` — to a host that is not loopback. Loopback is allowed because the suite's own `Bun.serve` servers are loopback. Raw `node:net`/`node:tls` are deliberately NOT guarded: that is how the Postgres driver reaches `TEST_DATABASE_URL`, which is deployment configuration and need not be loopback, and no vendor in this repository speaks a non-HTTP protocol. A deliberate live suite opts out with any non-empty `OPENBOT_LIVE_*` variable, which is what `bun run test:live-composio`, `test:live-arcade` and `test:live-screen` already set; `arcade-live.test.ts` and `composio-live.test.ts` stay skipped without a key (13 and 6 skipped, 0 run). The seam test is fixed at its own level too, by constructing the client inside the stubbed window, so the case still asserts what it is about — that `action` was not what stopped `authorize` — rather than tripping the guard. WHOLE-SUITE AUDIT, run rather than grepped. Each of the 279 non-integration files in `server/tests` was run under a preload that logged every non-loopback attempt. Two escaped: this one, and `learning-handoff.test.ts`, which POSTs to `https://telemetry.copilotkit.ai/ingest` through `@copilotkit/runtime`'s telemetry client on instance creation. The guard refuses both — recorded at the point of refusal in `globalThis.openbotTestNetworkRefusals` and read back in-process: `["https://telemetry.copilotkit.ai/ingest"]` for the handoff file, `[]` for the seam file, which is now answered by its own stub. The seven integration files that construct a vendor SDK were run the same way: nothing escaped but the same telemetry POST, now refused. All 279 files pass under the guard except `learning-langgraph.test.ts` and `learning-mastra.test.ts`, which fail identically before the change (a missing `agent-mastra` node_modules in this worktree). `bun test app/tests` is 1256 pass / 0 fail under the guard. Call sites of what this adds: - `server/scripts/test-network-guard.ts` (new module): imported once, by `server/scripts/test-preload.ts`. Both bunfig.toml files preload that script already, so neither changes. - `globalThis.openbotTestNetworkRefusals` (new global, declared in the guard): written only by the guard's `refuse`; read only by `server/tests/test-network-guard.test.ts`. Nothing in `server/src` touches it. - `server/tests/test-network-guard.test.ts` (new): three cases — a non-loopback request is refused, a server the suite started is not, and a vendor SDK that binds its own transport is refused. Nothing is renamed, resignatured or removed; `composio-broker-seam.test.ts` only moves one statement inside the stubbed window. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/scripts/test-network-guard.ts | 134 ++++++++++++++++++++++ server/scripts/test-preload.ts | 5 + server/tests/composio-broker-seam.test.ts | 17 ++- server/tests/test-network-guard.test.ts | 74 ++++++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 server/scripts/test-network-guard.ts create mode 100644 server/tests/test-network-guard.test.ts diff --git a/server/scripts/test-network-guard.ts b/server/scripts/test-network-guard.ts new file mode 100644 index 000000000..1474526c8 --- /dev/null +++ b/server/scripts/test-network-guard.ts @@ -0,0 +1,134 @@ +/** + * The test process's egress, closed at the transport rather than asked for politely file by file. + * + * A test that dials a vendor stops being a fact about this repository. It passes or fails on the + * vendor's uptime, on DNS and on whatever the network did that minute, so a red says nothing and a + * green says less than it looks like; it is slow and flaky in a suite this size; it carries + * whatever the test handed it — an API key, a user id, a return address — to a third party; and it + * is a real request against a real service, made from CI, on every run. + * + * EVERY TEST FILE ALREADY MEANT TO STUB, AND STUBBING PER FILE IS WHAT FAILED. `server/tests/ + * composio-broker-seam.test.ts` assigned `globalThis.fetch` around the call it was about and still + * reached `https://backend.composio.dev/api/v3.1/auth_configs` on every run, because the vendor + * client had been constructed one line EARLIER and `@composio/client` binds its transport once, at + * construction (`this.fetch = options.fetch ?? Shims.getDefaultFetch()`, `client.mjs:122`). A stub + * installed after that point is a stub the SDK never sees. That failure mode is invisible in review + * — the file reads as if it stubs — which is why the floor is here, in a preload that runs before + * any test file is imported and therefore before any vendor client can capture a transport. + * + * WHAT IS REFUSED: an HTTP request to anything that is not loopback. Loopback is allowed because + * the suite's own servers are loopback — `Bun.serve`, the route tests' ephemeral ports — and a test + * talking to a server it started is not talking to anyone. + * + * WHAT IS NOT GUARDED, AND WHY: raw `node:net`/`node:tls` sockets, which are how the Postgres + * driver reaches the test database. That host is deployment configuration (`TEST_DATABASE_URL`) and + * is not necessarily loopback, so a guard there would refuse the database rather than a vendor. No + * vendor SDK in this repository speaks a non-HTTP protocol: both brokered vendors, every model + * provider and every OAuth exchange go through `fetch`, and `node:http`/`node:https` are guarded + * alongside it so that a dependency which reaches for the older transport is caught too. + * + * HOW A SUITE THAT IS MEANT TO DIAL OPTS OUT: any `OPENBOT_LIVE_*` variable with a non-empty value. + * The three live suites already set one — `OPENBOT_LIVE_COMPOSIO`, `OPENBOT_LIVE_ARCADE` and + * `OPENBOT_LIVE_SCREEN` are set by their `test:live-*` scripts in `package.json`, which also refuse + * to run without a key, so a live suite remains deliberate and credentialed and stays skipped + * otherwise. + */ + +import http from "node:http"; +import https from "node:https"; + +const liveRun = Object.entries(process.env).some( + ([name, value]) => name.startsWith("OPENBOT_LIVE_") && (value ?? "").trim(), +); + +/** Loopback, in every spelling a URL or a Node request option can arrive in. */ +function isLoopback(host: string): boolean { + const bare = host.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + bare === "localhost" || + bare === "::1" || + bare === "0.0.0.0" || + bare === "::" || + bare === "" || + bare.endsWith(".localhost") || + /^127\./.test(bare) + ); +} + +/** + * Every target this guard refused, in order, on the global so a test can read it. + * + * A refusal does not always reach the caller as itself: `@composio/client` retries a rejected + * transport and then reports "Connection error.", which is the same sentence a real outage would + * produce. So the fact that the guard — rather than the network — stopped a call is recorded here + * at the point of refusal, where it is unambiguous. `server/tests/test-network-guard.test.ts` reads + * it; nothing in `server/src` may. + */ +declare global { + var openbotTestNetworkRefusals: string[] | undefined; +} + +globalThis.openbotTestNetworkRefusals = []; + +function refuse(target: string): Error { + globalThis.openbotTestNetworkRefusals?.push(target); + return new Error( + `A test tried to reach ${target}. Tests do not use the network: stub the transport this call ` + + "goes through (and stub it BEFORE constructing any client, which binds `fetch` once), or, " + + "for a deliberate vendor suite, set an OPENBOT_LIVE_* variable as `bun run test:live-*` " + + "does. Guard: server/scripts/test-network-guard.ts.", + ); +} + +if (!liveRun) { + const passThrough = globalThis.fetch; + + globalThis.fetch = ((input: unknown, init?: RequestInit) => { + const raw = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : ((input as { url?: string } | null)?.url ?? String(input)); + let url: URL | null = null; + try { + url = new URL(raw); + } catch { + // A relative or unparseable target reaches no host; the runtime's own error is the right one. + } + if (url && (url.protocol === "http:" || url.protocol === "https:")) { + if (!isLoopback(url.hostname)) { + return Promise.reject(refuse(url.origin + url.pathname)); + } + } + return passThrough(input as RequestInfo | URL, init); + }) as typeof globalThis.fetch; + + /* + * `request` is forwarded through an `unknown[]` signature rather than one of its four overloads: + * nothing here reads an argument it did not already read off the first one, and restating the + * overloads would be a declaration to maintain against Node's. + */ + type NodeRequest = (...args: unknown[]) => unknown; + for (const module of [http, https] as const) { + const passThroughRequest = module.request as unknown as NodeRequest; + (module as unknown as { request: NodeRequest }).request = ( + ...args: unknown[] + ) => { + const first = args[0]; + const target = + typeof first === "string" + ? new URL(first).hostname + : first instanceof URL + ? first.hostname + : ((first as { hostname?: string; host?: string } | null) + ?.hostname ?? + (first as { host?: string } | null)?.host ?? + ""); + if (!isLoopback(String(target).replace(/:\d+$/, ""))) { + throw refuse(String(target)); + } + return passThroughRequest(...args); + }; + } +} diff --git a/server/scripts/test-preload.ts b/server/scripts/test-preload.ts index beb9760ee..aecde0a84 100644 --- a/server/scripts/test-preload.ts +++ b/server/scripts/test-preload.ts @@ -20,10 +20,15 @@ * `@copilotkit/runtime` also eagerly imports the Vertex provider, which pulls `gaxios` through a * Bun global cache path during tests. These tests do not exercise Vertex, so the provider is stubbed * at the same preload boundary and fails loudly if a server test tries to use it. + * + * And `./test-network-guard` closes the process's egress, for the same reason this file exists at + * all: it has to run before any test file is imported, because a vendor client binds its transport + * when it is constructed and a stub installed afterwards is one the client never sees. */ import { mock } from "bun:test"; import "eventsource"; +import "./test-network-guard"; mock.module("@ai-sdk/google-vertex", () => ({ createVertex: () => () => { diff --git a/server/tests/composio-broker-seam.test.ts b/server/tests/composio-broker-seam.test.ts index 8325fa0c5..b06d1f367 100644 --- a/server/tests/composio-broker-seam.test.ts +++ b/server/tests/composio-broker-seam.test.ts @@ -184,8 +184,22 @@ describe("grantIsLocal", () => { ); }); + /** + * THE STUB IS INSTALLED BEFORE THE CLIENT IS CONSTRUCTED, AND THAT ORDER IS THE WHOLE TEST. + * + * `@composio/client` binds its transport once, in its constructor — `this.fetch = options.fetch + * ?? Shims.getDefaultFetch()` (`client.mjs:122`), and `getDefaultFetch` returns whatever + * `globalThis.fetch` is at that moment. This file used to construct the client on the line above + * the assignment, so the SDK held the real `fetch` and the stub below governed nothing: every run + * of this case reached `https://backend.composio.dev/api/v3.1/auth_configs` over the network, and + * the test passed anyway because the vendor's refusal of an unknown key also fails to mention + * `action`. Constructing after the assignment is what makes the stub the transport. + * + * `server/scripts/test-network-guard.ts` is the floor under this, and the two are not + * alternatives: the guard turns a call that escapes into a failure, and this ordering is what + * keeps the case asserting what it is about rather than tripping the guard. + */ test("a broker handed an action it cannot use ignores it rather than throwing", async () => { - const { broker } = createComposioClient("never-dialled"); const original = globalThis.fetch; // No network: an empty config listing, so `authorize` stops at its own refusal for having no // config to connect against. What is asserted is that `action` was not what stopped it. @@ -195,6 +209,7 @@ describe("grantIsLocal", () => { headers: { "content-type": "application/json" }, })) as unknown as typeof fetch; try { + const { broker } = createComposioClient("never-dialled"); const attempt = await broker .authorize({ account: { holder: "person", userId: "u", vendorUserId: "u" }, diff --git a/server/tests/test-network-guard.test.ts b/server/tests/test-network-guard.test.ts new file mode 100644 index 000000000..1cd97de5c --- /dev/null +++ b/server/tests/test-network-guard.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { createComposioClient } from "../src/plugins/composio-adapter"; + +/** + * The guard that makes a live vendor call impossible, asserted rather than assumed. + * + * `server/scripts/test-network-guard.ts` runs from the preload, before any test file is imported, + * and refuses every HTTP request to a host that is not loopback. It exists because stubbing per + * file does not hold: a vendor client binds its transport in its constructor, so a stub installed + * one line later governs nothing, and the file still reads as if it stubs. That is exactly how + * `composio-broker-seam.test.ts` reached `backend.composio.dev` on every run. + * + * Three facts are worth pinning. The guard refuses the far side; it does NOT refuse the suite's own + * loopback servers, which half the route tests are; and it is in force for a vendor SDK, which is + * the case it was written for and the one a per-file stub kept missing. + */ +describe("the test network guard", () => { + test("refuses a request to a host that is not loopback", async () => { + // example.com rather than a vendor: the assertion is that NOTHING is dialled, so the address + // this case names should be one no product code would ever have reason to reach. + const attempt = await fetch("https://example.com/anything").then( + () => "resolved", + (error: unknown) => (error instanceof Error ? error.message : ""), + ); + + expect(attempt).toContain("Tests do not use the network"); + expect(attempt).toContain("example.com"); + }); + + test("lets the suite talk to a server the suite started", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("local"), + }); + try { + const answer = await fetch(`http://127.0.0.1:${server.port}/`); + expect(await answer.text()).toBe("local"); + expect( + await (await fetch(`http://localhost:${server.port}/`)).text(), + ).toBe("local"); + } finally { + await server.stop(true); + } + }); + + /** + * The case the guard was written for, in the shape that defeated the per-file stub: the vendor + * client is constructed with no stub in sight, and its own bound transport is the guard. + */ + test("is in force for a vendor SDK that binds its own transport", async () => { + const { broker } = createComposioClient("never-dialled"); + + const attempt = await broker + .authorize({ + account: { holder: "person", userId: "u", vendorUserId: "u" }, + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }) + .then( + () => "resolved", + (error: unknown) => (error instanceof Error ? error.message : ""), + ); + + expect(attempt).not.toBe("resolved"); + /* + * ASSERTED ON THE GUARD'S OWN RECORD RATHER THAN THE MESSAGE, because the SDK retries the + * rejected transport and reports "Connection error." — the same sentence a real outage gives, + * so a test that read it could not tell a guarded call from a dialled one that failed. + */ + expect(globalThis.openbotTestNetworkRefusals?.join(" ")).toContain( + "backend.composio.dev", + ); + }); +}); From 346babd6f71c665ff8cc0a1559539c3e526e553d Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:31:09 +0200 Subject: [PATCH 150/232] test(arcade): see the listing defects the engine fixtures still could not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 was "the adapter test family is structurally blind by construction". Most of it no longer is: the C3 fix taught three fixtures the real page shape and the two HTTP-engine files let the SDK build its own pages. Mutation testing against the whole arcade suite (23 mutations of arcade-adapter.ts, each applied, run and reverted) found exactly three defect classes still invisible, all of them in pagedItems' contract with a real engine: * deleting the runaway guard's throw entirely (`if (!ended) throw new Error(listing.unending)`) left 265 pass / 0 fail. No test ever reached the bound, so a listing cut off at maxPages presented as the whole of itself: isConnected answering off a 10,000-row prefix and revoke reporting success after deleting a prefix of somebody's connections. * freezing the auth-provider listing's offset at 0, and sending NO paging parameters at all (`query: {}`), both left 265 pass / 0 fail. That listing is the one whose limit/offset travel inside a `query` wrapper, and no test had ever given it a second page, so on a project holding more than 100 provider configurations every person held under an instance past page one would read as not connected — invisibly. * a later page REVISING total_count downward (300, then 250) defeated the "highest total any page reported, never the last" rule with 265 pass / 0 fail. The existing test only drops the field from the short final page. Three tests close them, all in arcade-pagination-engine.test.ts and all in that file's established style: real JSON over a real socket through ARCADE_BASE_URL, the SDK building its own pages. Each now kills its mutation (1 or 2 fails) and the suite is green unmutated: 269 pass, 13 skip, 0 fail across the 31 arcade files. arcade-adapter.ts is byte-identical to a116c753. Prototype stubbing is deliberately LEFT in arcade-adapter.test.ts, arcade-auth-provider.test.ts and arcade-verify.test.ts: those tests are about this deployment's own folds (grantStateOf, listApps' tallies, the verifier's transaction), not about how the SDK builds or ends a page, and mutations of that logic are killed there already — 20 of the 23 mutations died in those files. Symbols added (all test-local, in arcade-pagination-engine.test.ts): * `authProviderRows(count)` — called once, from the engine's /v1/admin/auth_providers branch. * `EngineOptions.connectionsNeverEnd` — read once in the engine's /v1/admin/user_connections branch; set by the two "never ends" tests. * `EngineOptions.toolsLaterTotal` — read once in the engine's /v1/tools branch; set by the downward-revision test. * `EngineOptions.authProviders` — read twice in the engine's /v1/admin/auth_providers branch; set by the second-page test. No product symbol was added, renamed, resignatured or removed. Also merges this file's two `../src/plugins/arcade` imports, which biome flags on the file as it stands. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/tests/arcade-pagination-engine.test.ts | 173 +++++++++++++++++- 1 file changed, 163 insertions(+), 10 deletions(-) diff --git a/server/tests/arcade-pagination-engine.test.ts b/server/tests/arcade-pagination-engine.test.ts index c2820a1d0..49c84afd1 100644 --- a/server/tests/arcade-pagination-engine.test.ts +++ b/server/tests/arcade-pagination-engine.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { useArcadeClient } from "../src/plugins/arcade"; -import { listTools } from "../src/plugins/arcade"; +import { listTools, useArcadeClient } from "../src/plugins/arcade"; import { createArcadeClient } from "../src/plugins/arcade-adapter"; import type { AccountRef } from "../src/plugins/shared-accounts"; @@ -39,10 +38,26 @@ type EngineOptions = { * the live `"active"` the live API answers with. */ connectionStatuses?: (string | null)[]; + /** + * ANSWER EVERY CONNECTION PAGE FULL, FOREVER: no short page and no `total_count`, so nothing in + * the answer ever says the listing is over. This is the engine a runaway guard exists for. + */ + connectionsNeverEnd?: boolean; /** The toolkit listing claims this many actions... */ toolsClaimed?: number; /** ...and serves only this many, dropping `total_count` from the short final page. */ toolsServed?: number; + /** + * The `total_count` the toolkit listing reports on every page AFTER the first, when that differs + * from the first page's claim — a vendor revising its count downward mid-listing. + */ + toolsLaterTotal?: number; + /** + * How many auth-provider configurations the project holds. Only the LAST of them is the `google` + * instance the connection rows are held under; the rest are filler of another type. Left unset, + * the engine answers the single-config listing the other tests here want. + */ + authProviders?: number; }; type Engine = { @@ -75,6 +90,20 @@ const toolRow = (toolkit: string, n: number) => ({ }, }); +/** + * The project's auth-provider configurations, with the ONE that matters LAST. + * + * `id` is the provider INSTANCE and `provider_id` the TYPE, as the live project spells them. The + * filler carries type `github`, so a reader that stops before the end of this listing finds no + * `google` instance at all and answers "not connected" for a person who is. + */ +const authProviderRows = (count: number) => + Array.from({ length: count }, (_, n) => + n === count - 1 + ? { id: "google-openbot", provider_id: "google", type: "oauth2" } + : { id: `filler-${n}`, provider_id: "github", type: "oauth2" }, + ); + function statusOf(options: EngineOptions, n: number): string | null { const listed = options.connectionStatuses?.[n]; return listed === undefined ? "active" : listed; @@ -99,6 +128,16 @@ function startEngine(options: EngineOptions): Engine { return new Response(null, { status: 204 }); } if (url.pathname === "/v1/admin/user_connections") { + if (options.connectionsNeverEnd) { + return Response.json({ + items: Array.from({ length: limit }, (_, n) => + connectionRow(offset + n), + ), + offset, + limit, + page_count: limit, + }); + } const page = rows.slice(offset, Math.min(offset + limit, served)); const body: Record = { items: page, @@ -110,14 +149,28 @@ function startEngine(options: EngineOptions): Engine { return Response.json(body); } if (url.pathname === "/v1/admin/auth_providers") { + if (options.authProviders === undefined) { + return Response.json({ + items: [ + { id: "google-openbot", provider_id: "google", type: "oauth2" }, + ], + offset: 0, + limit, + page_count: 1, + total_count: 1, + }); + } + // PAGED, AND WITH NO `total_count`: a short page is the only thing that ends this listing, + // so a reader that does not advance its own offset never reaches the last configuration. + const page = authProviderRows(options.authProviders).slice( + offset, + offset + limit, + ); return Response.json({ - items: [ - { id: "google-openbot", provider_id: "google", type: "oauth2" }, - ], - offset: 0, + items: page, + offset, limit, - page_count: 1, - total_count: 1, + page_count: page.length, }); } if (url.pathname === "/v1/tools") { @@ -135,8 +188,12 @@ function startEngine(options: EngineOptions): Engine { page_count: page.length, }; // The short final page DROPS the field — which is what defeats a cross-check that keeps - // the LAST page's total rather than the highest one reported. - if (page.length === limit) body.total_count = claimed; + // the LAST page's total rather than the highest one reported. A later page may also REVISE + // the count downward, which defeats the same cross-check without dropping anything. + if (page.length === limit) { + body.total_count = + offset === 0 ? claimed : (options.toolsLaterTotal ?? claimed); + } return Response.json(body); } return new Response("not found", { status: 404 }); @@ -210,6 +267,84 @@ describe("the connection listing is read to its end, not to the SDK's", () => { }); }); +/** + * A LISTING THAT NEVER SAYS IT IS OVER IS NOT A COMPLETE ONE, AND THE RUNAWAY GUARD IS WHAT SAYS SO. + * + * Every other test here reaches a short page eventually, so the `maxPages` bound and the sentence + * behind it were never once reached: deleting the `unending` throw outright left the whole arcade + * suite green. What the throw prevents is the worst reading of all — a listing cut off at the bound + * presenting as the whole of itself, which is `isConnected` answering off an arbitrary 10,000-row + * prefix and `revoke` reporting success having deleted a prefix of somebody's connections. + * + * Only a real engine can pose the question: the SDK's own pagination stops at page one here (no + * `total_count`), and a hand-written generator ends when its fixture array does. + */ +describe("a listing that never ends is refused rather than cut off at the bound", () => { + test("isConnected refuses a connection listing that answers full pages forever", async () => { + const e = engine({ + connections: 0, + connectionTotal: false, + connectionsNeverEnd: true, + }); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/connection list never ended after 100 pages/); + // The bound is a bound: it asked 100 times and stopped. + expect( + e.requests.filter((r) => r.startsWith("/v1/admin/user_connections?")) + .length, + ).toBe(100); + }); + + test("revoke spends no delete on a listing that never ended", async () => { + const e = engine({ + connections: 0, + connectionTotal: false, + connectionsNeverEnd: true, + }); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + /connection list never ended after 100 pages/, + ); + expect(deletes(e).length).toBe(0); + }); +}); + +/** + * THE AUTH-PROVIDER LISTING IS PAGED TOO, AND ONLY A REAL ENGINE ASKS IT TO BE. + * + * This is the one listing whose paging parameters travel INSIDE a `query` wrapper + * (`list({ query: { limit, offset } })`), and the one no test ever gave a second page: every + * fixture and every engine here answered a single configuration. So freezing its offset at zero — + * or sending no paging parameters at all — changed nothing any test could see, while on a project + * holding more than 100 configurations it means every person connected under an instance past the + * first page reads as not connected at all. + */ +describe("the auth-provider listing is read past its first page", () => { + test("a connection held under an instance on the second page is still found", async () => { + const e = engine({ + connections: 1, + connectionTotal: false, + // 150 configurations: a full page, then a short one carrying the `google` instance. + authProviders: 150, + }); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + // Two pages were asked for, and the second one by offset — the SDK really does put the + // wrapper's fields on the query string. + expect( + e.requests.filter((r) => r.startsWith("/v1/admin/auth_providers")), + ).toEqual([ + "/v1/admin/auth_providers?limit=100&offset=0", + "/v1/admin/auth_providers?limit=100&offset=100", + ]); + }); +}); + /** * `connection_status` IS THE ROW'S OWN ANSWER TO "IS THIS USABLE", AND THE POSITIVE READS TAKE IT. * @@ -321,6 +456,24 @@ describe("the action listing's total_count cross-check survives a short final pa ); }); + test("a later page revising the count DOWN does not erase the first page's claim", async () => { + // Page one claims 300, page two revises it to 250, and 250 is all the engine serves. Keeping + // the LAST total any page reported — rather than the highest — makes the truncation agree with + // itself and the listing commits as a complete one. + engine({ + connections: 0, + connectionTotal: true, + toolsClaimed: 300, + toolsServed: 250, + toolsLaterTotal: 250, + }); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.listApps()).rejects.toThrow( + /listed 250 of 300 actions, so its app catalogue is incomplete/, + ); + }); + test("grantsFor pages past the first page", async () => { engine({ connections: 0, From 1d64fa2546d00da31b552e47608d8501df7596a4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:31:22 +0200 Subject: [PATCH 151/232] fix(arcade): refuse in this deployment's own sentence, not the vendor's body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of one defect, both at the adapter's vendor boundary. `execute` threw the normalized failure ITSELF: a plain `{ status, name, message }` object, not an `Error`. `reasonWithoutStatement` — the one door every log line in `routes.ts` copies a reason through — answers `String(thrown)` for a non-`Error`, so the single line written at the moment a tool call failed read `[object Object]`, with no stack beside it. And every other call to the vendor let the SDK's `APIError` escape untouched. That class builds its own `message` as `${status} ${body.message ?? JSON.stringify(body)}` (`@arcadeai/arcadejs`, `core/error.js: makeMessage`), so the vendor's RESPONSE BODY reached whatever quotes a message — a log line, the Plugins page's `lastError`, a model's context, an audit row. On this vendor a body is not inert: the auth paths answer with an `authorization` carrying a bearer `url` and a live `context.token`, which is exactly what this PR's second subject claim says never reaches any of those. Now: `ArcadeFailureError`, a real `Error` whose message is this deployment's sentence naming the step and the HTTP status, and whose constructor DROPS the vendor's prose while keeping the two machine fields decisions are keyed on (`status`, and the body's `error.name`). One `vendorCall` helper wraps all eleven `client.*` calls in the adapter. `normalizeArcadeError` reads the carried failure on its existing `cause` walk, so every dependent is unaffected: the authorize route's definite-or-uncertain decision (`status`), `callTool`'s authorize prompt and `confirmReturn`'s `user_mismatch` (the body's `error.name`). What is NOT an SDK error is still rethrown untouched, which keeps C1's measurement intact: a JSON body of literal `null` still escapes as the vendor's own `TypeError` from the `OffsetPage` constructor, and this file's own refusal sentences — the malformed listings, the mint with no url or no id — are unchanged. Call sites of what this adds or changes: - `ArcadeFailureError` (new, exported from `server/src/plugins/arcade.ts`): constructed in `arcade-adapter.ts` `vendorCall`; read by `arcade.ts` `arcadeFailure` (by type, first) and by `arcade-adapter.ts` `normalizeArcadeError`; asserted in `server/tests/arcade-vendor-error-engine.test.ts`. - `vendorCall` (new, private to `arcade-adapter.ts`): `listApps`' page, `authProviderInstances`' page, `connectionsFor`' page, `deleteConnection`, `grantsFor`' page, `authProviderOf`, `actions.listTools`, `actions.execute`, `broker.authorize`, `authorizations.status`, `authorizations.confirmReturn`. - `arcadeFailure` (`arcade.ts`, unexported): resignatured in body only, one call site — `callTool`'s catch. No signature change. - `normalizeArcadeError` (exported, unchanged signature): `routes.ts:2715`, `arcade-adapter.ts` (`vendorCall`, `confirmReturn`), and the adapter-error, live and new engine test files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 187 +++++++++++----- server/src/plugins/arcade.ts | 50 +++++ .../tests/arcade-vendor-error-engine.test.ts | 206 ++++++++++++++++++ 3 files changed, 394 insertions(+), 49 deletions(-) create mode 100644 server/tests/arcade-vendor-error-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 64cb081c0..5e7024c6c 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1,10 +1,15 @@ import Arcade, { APIError } from "@arcadeai/arcadejs"; -import { classify, type ArcadeActions, type ArcadeFailure } from "./arcade"; import { - BrokerRefusalError, - flagOf, + type ArcadeActions, + type ArcadeFailure, + ArcadeFailureError, + classify, +} from "./arcade"; +import { type BrokerApp, + BrokerRefusalError, type ConnectedAppBroker, + flagOf, type GrantState, } from "./broker"; @@ -54,6 +59,19 @@ export function normalizeArcadeError(error: unknown): ArcadeFailure | null { if (current === null || typeof current !== "object" || seen.has(current)) return null; seen.add(current); + /* + * A REFUSAL THIS FILE ALREADY NORMALIZED ANSWERS ITSELF, and it is read on the same chain walk + * rather than only at the top, because {@link vendorCall}'s answer is carried as a `cause` by + * the one place that wraps it again (`revoke`, which fails over several connections at once). + * + * THIS IS WHAT KEEPS THE MEDIATION FROM COSTING ANYTHING. Every reader of a failure out of this + * adapter — the authorize route's definite-or-uncertain decision, keyed on `status`; `callTool`'s + * authorize prompt and `confirmReturn`'s `user_mismatch`, both keyed on the body's `error.name` + * — asks through this function, so stopping the vendor's own `APIError` at the adapter boundary + * takes nothing away from any of them. The vendor's PROSE is the only thing that does not come + * back out; see {@link ArcadeFailureError}, which drops it. + */ + if (current instanceof ArcadeFailureError) return current.failure; if (current instanceof APIError) { const body = current.error; const record = @@ -71,6 +89,46 @@ export function normalizeArcadeError(error: unknown): ArcadeFailure | null { return null; } +/** + * ONE CALL TO THE VENDOR, WITH ITS REFUSAL TURNED INTO THIS DEPLOYMENT'S OWN SENTENCE. + * + * EVERY `client.*` CALL IN THIS FILE GOES THROUGH HERE, and that uniformity is the point rather + * than a tidiness: the thing being confined is the SDK's `APIError`, whose `message` is + * `${status} ${the response body}` (`core/error.js: makeMessage`), and one unwrapped call is one + * path on which a vendor body reaches a log line, the Plugins page's `lastError`, a model's context + * or an audit row. On this vendor a body is not inert — the auth paths answer with a bearer `url` + * and a live `context.token` — and the claim this adapter is held to is that neither ever reaches + * any of those. `step` is written HERE, at the call site, for the reason `callTool`'s refusal names + * the action from the call site: nothing in an `APIError` says what was being asked. + * + * WHAT IS NOT AN SDK ERROR IS RETHROWN UNTOUCHED, which is deliberate and is load-bearing twice + * over. It leaves this file's own refusals — the malformed-listing sentences, the mint with no url + * — exactly as they were; and it leaves the vendor's `TypeError` out of the SDK's `OffsetPage` + * constructor, which a JSON body of literal `null` raises, escaping as itself. Catching THAT by + * shape rather than by `normalizeArcadeError` answering is what would swallow genuine `APIError`s. + * + * A REFUSAL THAT IS ALREADY MEDIATED PASSES STRAIGHT THROUGH, so a nested call cannot dress one + * sentence up in another. + */ +async function vendorCall(step: string, run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof ArcadeFailureError) throw error; + const failure = normalizeArcadeError(error); + if (!failure) throw error; + throw new ArcadeFailureError( + failure.status === undefined + ? // `status` is undefined for `APIConnectionError` and `APIUserAbortError`: no response was + // ever read, so the outcome is uncertain rather than refused, and the sentence says so — + // the authorize route keys exactly that distinction off `status`. + `Arcade did not answer ${step}, so whether it ran is not known here.` + : `Arcade refused ${step} with HTTP ${failure.status}.`, + failure, + ); + } +} + /** Page size for the catalogue listing. A short page ends it, never `total_count`. */ const CATALOGUE_PAGE_SIZE = 100; /** A runaway guard, not a bound. */ @@ -259,7 +317,10 @@ async function listApps(client: Arcade): Promise { for await (const item of pagedItems({ pageSize: CATALOGUE_PAGE_SIZE, maxPages: CATALOGUE_MAX_PAGES, - page: (offset) => client.tools.list({ limit: CATALOGUE_PAGE_SIZE, offset }), + page: (offset) => + vendorCall("the app catalogue listing", () => + client.tools.list({ limit: CATALOGUE_PAGE_SIZE, offset }), + ), malformed: "Arcade did not answer with a tool list, so its app catalogue could not be read.", unending: `Arcade's catalogue never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, @@ -357,9 +418,13 @@ async function authProviderInstances( pageSize: AUTH_PROVIDER_PAGE_SIZE, maxPages: AUTH_PROVIDER_MAX_PAGES, page: (offset) => - client.admin.authProviders.list({ - query: { limit: AUTH_PROVIDER_PAGE_SIZE, offset }, - }), + vendorCall( + `the auth-provider configuration listing for ${authProvider}`, + () => + client.admin.authProviders.list({ + query: { limit: AUTH_PROVIDER_PAGE_SIZE, offset }, + }), + ), malformed: `Arcade did not answer with an auth-provider list, so the configurations of ${authProvider} its connections are held under could not be read.`, unending: `Arcade's auth-provider list never ended after ${AUTH_PROVIDER_MAX_PAGES} pages, so it is not trusted.`, incomplete: (seen, total) => @@ -450,11 +515,13 @@ async function connectionsFor( pageSize: CONNECTION_PAGE_SIZE, maxPages: CONNECTION_MAX_PAGES, page: (offset) => - client.admin.userConnections.list({ - user_id: vendorUserId, - limit: CONNECTION_PAGE_SIZE, - offset, - }), + vendorCall(`the connection listing for ${toolkit}`, () => + client.admin.userConnections.list({ + user_id: vendorUserId, + limit: CONNECTION_PAGE_SIZE, + offset, + }), + ), malformed: `Arcade did not answer with a connection list, so what this account holds at ${toolkit} could not be read.`, unending: `Arcade's connection list never ended after ${CONNECTION_MAX_PAGES} pages, so it is not trusted.`, incomplete: (seen, total) => @@ -522,7 +589,9 @@ async function deleteConnection( if (!connectionId) { throw new Error("Arcade connection has no connection_id to delete."); } - await client.admin.userConnections.delete(connectionId); + await vendorCall("a connection delete", () => + client.admin.userConnections.delete(connectionId), + ); } const record = (value: unknown): Record | null => @@ -636,12 +705,14 @@ async function grantsFor( pageSize: CATALOGUE_PAGE_SIZE, maxPages: CATALOGUE_MAX_PAGES, page: (offset) => - client.tools.list({ - toolkit, - user_id: vendorUserId, - limit: CATALOGUE_PAGE_SIZE, - offset, - }), + vendorCall(`the action listing for ${toolkit}`, () => + client.tools.list({ + toolkit, + user_id: vendorUserId, + limit: CATALOGUE_PAGE_SIZE, + offset, + }), + ), malformed: `Arcade did not answer with an action list for ${toolkit}, so what this account holds there could not be read.`, unending: `Arcade's action list for ${toolkit} never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, incomplete: (seen, total) => @@ -689,7 +760,9 @@ async function authProviderOf( client: Arcade, toolkit: string, ): Promise { - const page = await client.tools.list({ toolkit }); + const page = await vendorCall(`the action listing for ${toolkit}`, () => + client.tools.list({ toolkit }), + ); const items = sentItemsOf(page); if (!Array.isArray(items)) { throw new Error( @@ -709,10 +782,11 @@ async function authProviderOf( * The Arcade client, in the shape `createComposioClient` returns. `new Arcade({ apiKey })` opens no * socket and sends no request, so tests replace the SDK's PROTOTYPE methods before calling this. * - * `execute` REJECTS WITH THE NORMALIZED FAILURE WHEN THERE IS ONE. `callTool` expects an - * already-normalized, cause-unwrapped `ArcadeFailure`; a raw `APIError`'s `name` is the CLASS name - * through the prototype chain, never the body's `error.name`, so without this the authorize prompt - * is unreachable. `null` means "not an SDK error" and the original is rethrown. + * EVERY CALL TO THE VENDOR GOES THROUGH {@link vendorCall}, which is where a refusal becomes this + * deployment's own sentence and the vendor's body stops. `callTool` still reaches the body's + * `error.name` — a raw `APIError`'s `name` is the CLASS name through the prototype chain, never the + * body's, so without the normalization the authorize prompt would be unreachable — but it reaches + * it through {@link ArcadeFailureError}, not through a bare object thrown as if it were an error. */ export function createArcadeClient(apiKey: string): { actions: ArcadeActions; @@ -728,11 +802,15 @@ export function createArcadeClient(apiKey: string): { // carried no total and its `raw.length < total` cross-check could never fire. The FIELD IS // OMITTED when the vendor omitted it, which is what the declared `total_count?: number` means. listTools: async (q) => { - const answer = await client.tools.list({ - toolkit: q.toolkit, - limit: q.limit, - offset: q.offset, - }); + const answer = await vendorCall( + `the action listing for ${q.toolkit}`, + () => + client.tools.list({ + toolkit: q.toolkit, + limit: q.limit, + offset: q.offset, + }), + ); // Passed through unchecked: a malformed listing is the transport's sentence to write, and // its own `Array.isArray` guard is what writes it. THE ITEMS ARE READ OFF THE RAW BODY for // {@link sentItemsOf}'s reason — handing over the page's manufactured `[]` would leave that @@ -742,19 +820,14 @@ export function createArcadeClient(apiKey: string): { const total = reportedTotalOf(answer); return total === undefined ? { items } : { items, total_count: total }; }, - execute: async (r) => { - try { - return await client.tools.execute({ + execute: async (r) => + vendorCall(`running ${r.tool_name}`, () => + client.tools.execute({ tool_name: r.tool_name, input: r.input, user_id: r.user_id, - }); - } catch (error) { - const failure = normalizeArcadeError(error); - if (failure) throw failure; - throw error; - } - }, + }), + ), }; const broker: ConnectedAppBroker = { grantIsLocal: false, @@ -807,10 +880,18 @@ export function createArcadeClient(apiKey: string): { // // `returnUrl` IS ACCEPTED AND IGNORED, for the same reason: the landing page comes from the // attempt row (spec D18), not from a parameter this vendor will take. - const answer = await client.tools.authorize({ - tool_name: action, - user_id: account.vendorUserId, - }); + // + // THE REFUSAL IS MEDIATED, AND ON THIS CALL THAT IS NOT A STYLE POINT: the body an + // authorization answers with is the one that carries a bearer `url`, and the SDK's `APIError` + // puts the body it could not read a `message` off into its own `message`. See + // {@link vendorCall}; the route's definite-or-uncertain decision reads `status` through + // `normalizeArcadeError` and is unaffected. + const answer = await vendorCall(`the authorization for ${action}`, () => + client.tools.authorize({ + tool_name: action, + user_id: account.vendorUserId, + }), + ); // The url is a bearer capability: it goes back to the caller and nowhere else. const url = answer.url; if (typeof url !== "string" || url === "") { @@ -889,7 +970,11 @@ export function createArcadeClient(apiKey: string): { * payload by code that never sees it. Do not widen this, and do not add `url`. */ status: async (id) => { - const answer = await client.auth.status({ id }); + // THE REFUSAL IS MEDIATED TOO, for the same reason the RESPONSE is: a status this vendor + // would not answer is answered ABOUT a flow whose body carries the token and the url. + const answer = await vendorCall("an authorization status read", () => + client.auth.status({ id }), + ); const scopes = answer.scopes; return { status: textOf(answer.status) ?? "unknown", @@ -901,13 +986,17 @@ export function createArcadeClient(apiKey: string): { }, // `handle` is opaque to the seam; `flow_id` is Arcade's word for it and stops in this file. // A `user_mismatch` answer is an ANSWER, so it surfaces as `{ mismatch: true }`; any other - // failure is an outage or a bug and is rethrown untouched. + // failure is an outage or a bug and is rethrown as the sentence {@link vendorCall} wrote for + // it — `user_mismatch` is still read, because the body's `error.name` survives the mediation + // and `normalizeArcadeError` answers off the carried failure. confirmReturn: async ({ handle, vendorUserId }) => { try { - const answer = await client.auth.confirmUser({ - flow_id: handle, - user_id: vendorUserId, - }); + const answer = await vendorCall("the return-leg confirmation", () => + client.auth.confirmUser({ + flow_id: handle, + user_id: vendorUserId, + }), + ); return { authorizationId: answer.auth_id }; } catch (error) { if (normalizeArcadeError(error)?.name === "user_mismatch") { diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 9f03963de..91fd7dc7f 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -255,6 +255,50 @@ export type ArcadeFailure = { message: string | undefined; }; +/** + * A REFUSAL FROM ARCADE AS A REAL `Error`, CARRYING THIS DEPLOYMENT'S SENTENCE AND NOT THE VENDOR'S. + * + * TWO DEFECTS IN ONE CLASS, and both were measured rather than imagined. + * + * A BARE OBJECT IS NOT A REASON. `createArcadeClient`'s `execute` used to `throw` the normalized + * failure itself — a plain `{ status, name, message }`. `reasonWithoutStatement`, which is the one + * door every log line in `routes.ts` copies a reason through, answers `String(thrown)` for anything + * that is not an `Error`: the literal text `[object Object]`. So the single log line written at the + * moment a tool call failed carried no information whatsoever, and there was no stack beside it. + * + * AND THE VENDOR'S WORDS ARE DROPPED HERE, which is the half the type enforces. The SDK's `APIError` + * builds its own `message` as `${status} ${body.message ?? JSON.stringify(body)}` + * (`@arcadeai/arcadejs`, `core/error.js: makeMessage`), so an `APIError` allowed to escape the + * adapter puts the vendor's RESPONSE BODY into every reader that quotes a message — an admin page, + * a log line, a model's context, an audit row. On this vendor that body is not inert: the auth + * paths answer with an `authorization` whose `url` is a bearer capability and whose `context.token` + * is a live provider access token, and the adapter's `authorizations.status` already inverts this + * codebase's carry-the-vendor-error-as-`cause` convention for exactly that reason. This is the same + * refusal one layer out: the constructor keeps `status` and the body's `error.name` — the two + * MACHINE fields this deployment keys decisions on, in {@link isAuthorizationRequired} and in the + * route that decides whether a failed mint is definite — and drops the vendor's PROSE on the floor. + * Nothing that is not stored cannot be logged. + * + * `status` IS ALSO A TOP-LEVEL FIELD, because that is what an `APIError` presents and callers that + * read it off the thrown value go on working across this change. + */ +export class ArcadeFailureError extends Error { + readonly status: number | undefined; + readonly failure: ArcadeFailure; + constructor(sentence: string, failure: ArcadeFailure) { + super(sentence); + this.name = "ArcadeFailureError"; + this.status = failure.status; + // THE ONE DOOR THE VENDOR'S PROSE DOES NOT COME THROUGH. Written here rather than at each + // construction site so that a later caller cannot pass it on by forgetting to. + this.failure = { + status: failure.status, + name: failure.name, + message: undefined, + }; + } +} + /** * KEYED ON THE BODY'S NAME, NEVER ON THE 403. A 403 is also what a revoked key, a disabled project * and a rate limit look like; keyed on the status, this would tell somebody to authorize an action @@ -269,8 +313,14 @@ export function isAuthorizationRequired(failure: ArcadeFailure): boolean { * that normalizes nothing and walks no `cause`, so a cast through it would let any object at all * stand in for a normalized failure. Only a `name` string is required, since that is the one field * `isAuthorizationRequired` keys on; the rest are read as-is or left undefined. + * + * {@link ArcadeFailureError} IS ASKED FIRST AND BY TYPE, because the shape reading cannot see it: + * an `Error`'s `name` is its CLASS name through the prototype chain, so a duck-typed read of one + * answers `"ArcadeFailureError"` for every refusal and the authorize prompt below would be + * unreachable — which is the same trap the adapter's own comment records for a raw `APIError`. */ function arcadeFailure(thrown: unknown): ArcadeFailure | null { + if (thrown instanceof ArcadeFailureError) return thrown.failure; const raw = record(thrown); if (!raw || typeof raw.name !== "string") return null; return { diff --git a/server/tests/arcade-vendor-error-engine.test.ts b/server/tests/arcade-vendor-error-engine.test.ts new file mode 100644 index 000000000..1145057ff --- /dev/null +++ b/server/tests/arcade-vendor-error-engine.test.ts @@ -0,0 +1,206 @@ +// server/tests/arcade-vendor-error-engine.test.ts +/** + * WHAT A REFUSAL FROM ARCADE LOOKS LIKE BY THE TIME SOMEBODY READS IT. + * + * Two properties, measured on every door the adapter opens to the vendor: + * + * 1. WHAT IS THROWN IS AN `Error`. A bare object has no `message` and no stack, and + * `reasonWithoutStatement` — the one door every log line in `routes.ts` copies a reason through + * — answers `String(thrown)` for a non-`Error`, which is the literal text `[object Object]`. + * The one log line written at the moment a tool call failed then carries no information at all. + * + * 2. NOTHING THE VENDOR WROTE IS IN IT. The SDK's `APIError` builds its own `message` as + * `${status} ${body.message ?? JSON.stringify(body)}` (`core/error.js`, `makeMessage`), so an + * `APIError` left to escape puts the vendor's response body — which on this vendor's auth paths + * can carry a bearer url — into whatever log, admin page or error answer quotes the message. + * This PR's second subject claim is that such a thing never reaches a log, an error body, an + * audit row or a model, so the vendor's words stop at this adapter and this deployment's own + * sentence goes on from there. + * + * AND THE DEPENDENTS OF THE REFUSAL STILL WORK, which is the other half of the same test: the + * normalized `status` that `routes.ts` keys its discard-or-keep decision on, the body's `error.name` + * that `callTool` keys the authorize prompt on, and the `user_mismatch` answer `confirmReturn` + * reads, are all still reachable through `normalizeArcadeError`. + * + * SERVED OVER A REAL SOCKET, as `arcade-pagination-engine.test.ts` is and for its reason: the rest + * of the adapter suite stubs SDK resource PROTOTYPES, which hands the adapter a hand-built + * rejection rather than the error the SDK itself builds out of a real response — and the error the + * SDK builds is the whole subject here. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { reasonWithoutStatement } from "../src/db/query-failure"; +import { callTool, useArcadeClient } from "../src/plugins/arcade"; +import { + createArcadeClient, + normalizeArcadeError, +} from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +/** A bearer capability in the vendor's prose: whoever reads it can attach an account. */ +const BEARER = + "https://cloud.arcade.dev/auth/callback?flow=flow-abc&secret=SECRET-BEARER-f00d"; +/** The vendor's own sentence, which this deployment never repeats. */ +const VENDOR_PROSE = `tool not authorized; send the person to ${BEARER}`; + +type Engine = { port: number; requests: string[]; stop: () => void }; + +/** + * Every endpoint the adapter can reach, refusing with 403 and Arcade's own body shape + * (`{ name, message }` — the shape `normalizeArcadeError` reads). 403 and not 500: the SDK retries + * 5xx twice with backoff, and nothing here is about retries. + */ +function startEngine(): Engine { + const requests: string[] = []; + const refusal = () => + Response.json( + { name: "tool_authorization_required", message: VENDOR_PROSE }, + { status: 403 }, + ); + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(url.pathname); + return refusal(); + }, + }); + return { port: server.port, requests, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(): Engine { + running = startEngine(); + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +/** What a log line would carry, read through the door every log line in `routes.ts` reads through. */ +const asLogged = (thrown: unknown): string => reasonWithoutStatement(thrown); + +async function refusalFrom(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return error; + } + throw new Error("the engine refused and the adapter did not."); +} + +describe("a refusal from Arcade is an Error this deployment wrote", () => { + const doors: [ + string, + (c: ReturnType) => Promise, + ][] = [ + [ + "execute", + ({ actions }) => + actions.execute({ + tool_name: "Gmail.SendEmail", + input: {}, + user_id: account.vendorUserId, + }), + ], + [ + "listTools", + ({ actions }) => + actions.listTools({ toolkit: "Gmail", limit: 100, offset: 0 }), + ], + ["listApps", ({ broker }) => broker.listApps()], + [ + "authorize", + ({ broker }) => broker.authorize({ account, action: "Gmail.SendEmail" }), + ], + [ + "isConnected", + ({ broker }) => broker.isConnected({ account, toolkit: "Gmail" }), + ], + [ + "accountName", + ({ broker }) => broker.accountName({ account, toolkit: "Gmail" }), + ], + ["revoke", ({ broker }) => broker.revoke({ account, toolkit: "Gmail" })], + ["revokeAccount", ({ broker }) => broker.revokeAccount("ac_1")], + [ + "grantsFor", + ({ broker }) => broker.grantsFor({ account, toolkit: "Gmail" }), + ], + ["authProviderOf", ({ broker }) => broker.authProviderOf("Gmail")], + [ + "authorizations.status", + ({ broker }) => + broker.authorizations?.status("ar_1") ?? + Promise.reject(new Error("no status member")), + ], + [ + "authorizations.confirmReturn", + ({ broker }) => + broker.authorizations?.confirmReturn({ + handle: "flow-abc", + vendorUserId: account.vendorUserId, + }) ?? Promise.reject(new Error("no confirmReturn member")), + ], + ]; + + for (const [door, call] of doors) { + test(`${door} throws an Error carrying no vendor words`, async () => { + engine(); + const client = createArcadeClient("never-dialled"); + const thrown = await refusalFrom(() => call(client)); + + // 1. AN `Error`, so a log line has something to say. + expect(thrown).toBeInstanceOf(Error); + expect(asLogged(thrown)).not.toBe("[object Object]"); + expect(asLogged(thrown).length).toBeGreaterThan(0); + + // 2. NOTHING THE VENDOR WROTE, at any depth a reader can reach. + expect(asLogged(thrown)).not.toContain(BEARER); + expect(asLogged(thrown)).not.toContain(VENDOR_PROSE); + expect( + JSON.stringify(thrown, Object.keys(thrown as object)), + ).not.toContain("SECRET-BEARER"); + let cause: unknown = (thrown as { cause?: unknown }).cause; + for (let depth = 0; depth < 8 && cause !== undefined; depth += 1) { + expect(asLogged(cause)).not.toContain(BEARER); + cause = (cause as { cause?: unknown }).cause; + } + + // 3. AND THE STATUS `routes.ts` KEYS ITS DECISION ON IS STILL READABLE: an answered status is + // what tells a definite refusal from a connection that may have started a flow. + expect(normalizeArcadeError(thrown)?.status).toBe(403); + }); + } + + test("the body's error.name still reaches callTool's authorize sentence", async () => { + engine(); + const { actions } = createArcadeClient("never-dialled"); + useArcadeClient(actions); + + const result = await callTool( + { url: "arcade://Gmail", actorId: account.vendorUserId }, + "Gmail.SendEmail", + {}, + ); + expect(result.isError).toBe(true); + expect(result.text).toBe( + "Gmail.SendEmail has not been authorized on this account yet. Authorize it in Settings → Connected accounts and ask again.", + ); + // AND THE VENDOR'S WORDS ARE NOT IN WHAT A MODEL IS HANDED. + expect(result.text).not.toContain(BEARER); + }); +}); From 8225e7d5b57e6edd60a744c99accfc8f2bbe3039 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:31:58 +0200 Subject: [PATCH 152/232] test(app): pin the callbackURL a sign-in is given, not just its default Three mutations to real app behaviour left all 1256 app tests green: 1. the sign screen dropping the resume callbackURL from the company-account (SSO) leg entirely; 2. `signInWith` discarding the callbackURL it is handed and sending `window.location.origin` instead; 3. `signInWithEmailDomain` doing the same. The reasons were specific. `sign-resume.test.tsx` replaces `@/lib/auth/client` at the module boundary, so nothing there can see the real functions drop an argument; its `signInWithEmailDomain` stub was `async () => {}` with no assertion behind it, so the SSO leg was unwatched end to end. And `auth-client.test.ts` only ever called the no-argument form, so "sends the browser back where it started" passes just as well for a body that builds the origin for itself and never reads its third parameter. That discarded callbackURL is the mechanism behind the consent-resume defect being fixed separately (D8): the verifier is never asked for and the browser lands on the home screen. The seam is now covered from both sides. `auth-client.test.ts` drives the real functions through their injectable starter and pins that a given callbackURL arrives verbatim, and gives `signInWithEmailDomain` the tests it never had at all (address, default, refusal message, named default refusal, quiet resolve). `sign-resume.test.tsx` spies on both legs and asserts the screen hands each the resume URL, with the render extracted to a helper so the SSO deployment shape (no provider buttons, form only) can be rendered too. The same shape one surface over: both brokered consent starts take a `returnTo` defaulting to `"settings"`, and every existing assertion drove them with no argument, so a body ignoring the parameter passed. Pinned with the `"admin"` call, whose defect is an administrator put down in their personal settings after consenting from the connector's admin page. `authorizeActionMutationOptions` had no test at all. Tests only: no file under app/src is touched. No symbol added, renamed, resignatured or removed, so there are no call sites to enumerate; the one new import is `authorizeActionMutationOptions` from `app/src/lib/plugins/grants.ts` into `app/tests/plugin-mutation-refresh.test.ts`. Mutation evidence, whole app suite each time: baseline 1265 pass / 0 fail; (1) 1 fail, the company-account resume test; (2)+(3) 2 fail, both "carries the callbackURL it was given" tests; (4) returnTo ignored, 1 fail, the consent starts test. Reverted: 1265 pass / 0 fail, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- app/tests/auth-client.test.ts | 109 +++++++++++++++++++++- app/tests/plugin-mutation-refresh.test.ts | 25 +++++ app/tests/sign-resume.test.tsx | 84 ++++++++++++++--- 3 files changed, 203 insertions(+), 15 deletions(-) diff --git a/app/tests/auth-client.test.ts b/app/tests/auth-client.test.ts index 86a7ede9a..2ae0bf7af 100644 --- a/app/tests/auth-client.test.ts +++ b/app/tests/auth-client.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { providerName, signInWith } from "@/lib/auth/client"; +import { + providerName, + signInWith, + signInWithEmailDomain, +} from "@/lib/auth/client"; /* * A browser origin, which the sign-in call needs for its callback URL and this environment has no @@ -45,6 +49,32 @@ describe("signInWith", () => { expect(callbackURL).toBe("http://localhost:3010"); }); + /** + * A callbackURL that was asked for is the one that gets used. + * + * THE DEFAULT ABOVE IS NOT THE PROPERTY. Pinning only the no-argument case leaves the whole + * resume mechanism untested: a body that builds `window.location.origin` for itself and never + * reads its third parameter passes "sends the browser back where it started" exactly as this + * one does, and the brokered flow somebody was signing back in to resume ends at the home + * screen with the verifier never asked for. The URL asserted here is the shape + * `resumeUrlFor` produces, since that is the only caller that passes one. + */ + test("carries the callbackURL it was given, not the origin", async () => { + const resume = "http://localhost:3010/api/plugins/arcade/verify?flow_id=h1"; + let asked = ""; + + await signInWith( + "google", + async (input) => { + asked = input.callbackURL; + return {}; + }, + resume, + ); + + expect(asked).toBe(resume); + }); + test("throws what the client said when it refuses", async () => { const refuse = async () => ({ error: { message: "That provider is not configured." }, @@ -74,6 +104,83 @@ describe("signInWith", () => { }); }); +/** + * Starting sign-in through whichever identity provider covers an address. + * + * The SSO leg had no test of its own, and the screen test that drives it replaces this module, so + * everything below was unpinned: the address could have been dropped, the callbackURL discarded, + * the refusal swallowed. It is the same call as `signInWith` with a different first argument, and + * it carries the resume URL for the same reason — somebody whose session lapsed mid-authorization + * signs back in through their company's IdP and has to land on the verifier, not on the home + * screen. + */ +describe("signInWithEmailDomain", () => { + test("hands the address to the starter", async () => { + let asked = ""; + + await signInWithEmailDomain("someone@company.com", async (input) => { + asked = input.email; + return {}; + }); + + expect(asked).toBe("someone@company.com"); + }); + + test("sends the browser back where it started", async () => { + let callbackURL = ""; + + await signInWithEmailDomain("someone@company.com", async (input) => { + callbackURL = input.callbackURL; + return {}; + }); + + expect(callbackURL).toBe("http://localhost:3010"); + }); + + /** The resume half, for the reason given over the same test for `signInWith`. */ + test("carries the callbackURL it was given, not the origin", async () => { + const resume = "http://localhost:3010/api/plugins/arcade/verify?flow_id=h1"; + let asked = ""; + + await signInWithEmailDomain( + "someone@company.com", + async (input) => { + asked = input.callbackURL; + return {}; + }, + resume, + ); + + expect(asked).toBe(resume); + }); + + test("throws what the client said when it refuses", async () => { + const refuse = async () => ({ + error: { message: "That domain is registered to another tenant." }, + }); + + expect( + signInWithEmailDomain("someone@company.com", refuse), + ).rejects.toThrow("That domain is registered to another tenant."); + }); + + test("says no provider is registered when the client says nothing", async () => { + const refuse = async () => ({ error: {} }); + + expect( + signInWithEmailDomain("someone@company.com", refuse), + ).rejects.toThrow("No identity provider is registered for that address."); + }); + + test("resolves quietly when the redirect is under way", async () => { + expect( + signInWithEmailDomain("someone@company.com", async () => ({ + error: null, + })), + ).resolves.toBeUndefined(); + }); +}); + describe("providerName", () => { test("gives each provider the name people call it", () => { expect(providerName("google")).toBe("Google"); diff --git a/app/tests/plugin-mutation-refresh.test.ts b/app/tests/plugin-mutation-refresh.test.ts index 67c21c427..3dd72fd7f 100644 --- a/app/tests/plugin-mutation-refresh.test.ts +++ b/app/tests/plugin-mutation-refresh.test.ts @@ -7,6 +7,7 @@ import { mutationOptions, QueryClient, } from "@tanstack/react-query"; +import { authorizeActionMutationOptions } from "../src/lib/plugins/grants"; import * as pluginMutations from "../src/lib/plugins/mutations"; import { accountModePreview, @@ -642,3 +643,27 @@ test("every id these writes interpolate into a URL goes through encodeURICompone expect(raw).toEqual([]); }); + +/** + * Where the vendor's callback puts somebody down is the caller's to say, and only the default was + * ever pinned. + * + * The roster above drives both consent starts with no argument, so `?returnTo=settings` is all any + * assertion in this file has ever seen. A body that ignored its parameter and wrote `settings` + * itself would pass every one of them — and the defect that hides is an administrator who presses + * Connect on the connector's admin page, finishes consent at the vendor, and is put down in their + * own personal settings: the round trip the inline row exists to remove. One call each, with the + * value the default cannot be confused with. + */ +test("the consent starts carry the screen they were started from", async () => { + expect( + await requestedBy(() => connectAccountMutationOptions("admin"), HOSTILE_ID), + ).toEqual([`/api/plugins/servers/${ENCODED_ID}/connect?returnTo=admin`]); + + expect( + await requestedBy(() => authorizeActionMutationOptions("admin"), { + serverId: HOSTILE_ID, + action: "GMAIL_SEND_EMAIL", + }), + ).toEqual([`/api/plugins/servers/${ENCODED_ID}/authorize?returnTo=admin`]); +}); diff --git a/app/tests/sign-resume.test.tsx b/app/tests/sign-resume.test.tsx index da311f8ba..8f6539be6 100644 --- a/app/tests/sign-resume.test.tsx +++ b/app/tests/sign-resume.test.tsx @@ -21,12 +21,27 @@ type SignInSpy = ( start: unknown, callbackURL?: string, ) => Promise; +type DomainSignInSpy = ( + email: string, + start: unknown, + callbackURL?: string, +) => Promise; const realAuthClient = { ...(await import("@/lib/auth/client")) }; let signInSpy: SignInSpy = async () => {}; +let domainSignInSpy: DomainSignInSpy = async () => {}; +/* + * BOTH LEGS ARE SPIED, AND NEITHER IS A NO-OP. Replacing the module is how the screen is tested at + * all, but a replacement nobody asserts against tests nothing: `signInWithEmailDomain` stood here + * as `async () => {}` while the SSO form's callbackURL went unpinned, so the screen could stop + * passing the resume URL and this file stayed green. What the module boundary cannot see — the + * real client dropping a callbackURL it was handed — is pinned in `auth-client.test.ts`, which + * drives the real functions through their injectable starter. The two together cover the handoff. + */ mock.module("@/lib/auth/client", () => ({ providerName: () => "Google", signInWith: (...args: Parameters) => signInSpy(...args), - signInWithEmailDomain: async () => {}, + signInWithEmailDomain: (...args: Parameters) => + domainSignInSpy(...args), })); const { Route } = await import("@/routes/sign"); @@ -101,17 +116,14 @@ test("a signed-in arrival with a handle goes to the verifier, without one to /", expect(without.options.href).toBeUndefined(); }); -test("the sign-in starter gets the resume URL as its callbackURL", async () => { +/** The sign screen on `/sign?flow_id=h1`, with whichever ways in the deployment offers. */ +function renderSignScreen(options: { + providers: string[]; + sso: boolean; + ssoRequired: boolean; +}) { const queryClient = new QueryClient(); - queryClient.setQueryData(authKeys.providers(), { - providers: ["google"], - sso: false, - ssoRequired: false, - }); - const asked: string[] = []; - signInSpy = async (_provider, _start, callbackURL) => { - asked.push(callbackURL ?? ""); - }; + queryClient.setQueryData(authKeys.providers(), options); queryClient.setQueryData(authKeys.currentUser(), null); const root = createRootRoute(); @@ -131,10 +143,54 @@ test("the sign-in starter gets the resume URL as its callbackURL", async () => { , ); - const user = userEvent.setup({ document: view.container.ownerDocument }); + return { + view, + user: userEvent.setup({ document: view.container.ownerDocument }), + }; +} + +/** Where a press on this screen has to send the browser back to, with `?flow_id=h1` carried in. */ +const RESUME_URL = "/api/plugins/arcade/verify?flow_id=h1"; + +test("the sign-in starter gets the resume URL as its callbackURL", async () => { + const asked: string[] = []; + signInSpy = async (_provider, _start, callbackURL) => { + asked.push(callbackURL ?? ""); + }; + + const { view, user } = renderSignScreen({ + providers: ["google"], + sso: false, + ssoRequired: false, + }); await user.click(await view.findByText(/Continue with Google/)); await waitFor(() => expect(asked).toHaveLength(1)); - expect(asked[0]).toBe( - `${window.location.origin}/api/plugins/arcade/verify?flow_id=h1`, + expect(asked[0]).toBe(`${window.location.origin}${RESUME_URL}`); +}); + +/** + * The company-account way in resumes too, and it was the leg nothing watched. + * + * A deployment whose people arrive through their own IdP has no provider buttons to press, so the + * form below them IS the sign-in for everybody there. Leaving its callbackURL unpinned meant the + * resume could be dropped for exactly those deployments and every test stayed green. + */ +test("the company-account form gets the resume URL as its callbackURL", async () => { + const asked: string[] = []; + domainSignInSpy = async (_email, _start, callbackURL) => { + asked.push(callbackURL ?? ""); + }; + + const { view, user } = renderSignScreen({ + providers: [], + sso: true, + ssoRequired: false, + }); + await user.type( + await view.findByPlaceholderText("you@company.com"), + "someone@company.com", ); + await user.click(await view.findByText(/Continue with your company account/)); + await waitFor(() => expect(asked).toHaveLength(1)); + expect(asked[0]).toBe(`${window.location.origin}${RESUME_URL}`); }); From 334d805463d5d739e88d93be4ba8d6932862919d Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:45:02 +0200 Subject: [PATCH 153/232] fix(arcade): read the auth provider off the WHOLE action listing, not page one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authProviderOf` sent ONE request — `client.tools.list({ toolkit })`, with no `limit`, no `offset` and no loop — while the file's three other listings all page to their end through `pagedItems`. Its own docblock promised that a toolkit whose actions disagree is left UNANSWERED rather than "silently resolved to whichever action came first"; the guard that was supposed to deliver that (distinct provider ids, anything but exactly one answers `null`) was reading a PREFIX, and a prefix agrees with itself. Measured against a real HTTP engine: a 150-action `Gmail` listing whose first 100 actions sit under `google` and whose last 50 sit under `microsoft` resolved CONFIDENTLY to `google` off the single request `/v1/tools?toolkit=Gmail`. The request carried no `limit` at all, so the window it judged from was the vendor's default rather than `CATALOGUE_PAGE_SIZE`. This disproves the reasoning C3 recorded when it deliberately left the call unpaged — "it answers one fact for the whole toolkit, so a further page can only repeat the first". "Every tool in a toolkit shares its auth provider" is a claim about what a toolkit ought to be, not about what the vendor sends; reading what the vendor sends is this function's entire job. That comment is corrected in place so the next reader is not misled by it. Why it matters: `authProviderOf` feeds `connectionsFor`, which scopes `isConnected`, `accountName` and `revoke` to a provider-instance set. The wrong provider aims all three at the wrong set — the family of the live defect fixed in 28b13e4a, where `revoke` answered without spending a delete. Fix: page it through `pagedItems` with `CATALOGUE_PAGE_SIZE` / `CATALOGUE_MAX_PAGES`, like `listApps`, `grantsFor` and `connectionsFor`. The distinct provider ids are now collected across the whole listing, so the existing guard finally fires. A truncated or never-ending listing now THROWS here too, rather than resolving a provider off whatever arrived. What "unanswered" means: `null`, unchanged — it already meant exactly this. Its sole caller `connectionsFor` answers NO ROWS for `null` and explicitly never falls back to the toolkit name, so `isConnected` reads false, `accountName` null and `revoke` false WITHOUT SPENDING A DELETE. That is fail-closed on all three, which is what an ambiguous toolkit deserves, so no second spelling of "unanswered" was invented. A toolkit carrying no provider id anywhere lands on the same answer for the same reason. Same-pattern audit of the whole file: no other instance. `listApps` (:409), `authProviderInstances` (:522), `connectionsFor` (:619) and `grantsFor` (:853) all already page through `pagedItems`; `authProviderOf` was the only unpaged listing, as its own comment said. The remaining `vendorCall`s (:716 delete, :1038 authorize, :1160 status, :1180 return leg) are single-object calls, not listings. `createArcadeClient.listTools` is the transport's one-page primitive whose caller supplies `limit`/`offset` and does the paging. No other multi-valued answer is collapsed to its first element: the only `[0]`s are `[...named][0]` behind the `size === 1` guard and `failed[0]` as an error cause. `accountName` returning the first LIVE row's name is a choice over a fully paged set, not a first-page read, and is left alone. Call sites: no symbol was added, renamed, re-signatured or removed. `authProviderOf(client, toolkit)` keeps its signature and its `Promise` return; its call sites are unchanged — `connectionsFor` (server/src/plugins/arcade-adapter.ts:615) and the broker's `authProviderOf: (toolkit) => authProviderOf(client, toolkit)` (server/src/plugins/arcade-adapter.ts:1136). The test-only engine option `toolProviderSplitAt` / `toolLaterProvider` is local to server/tests/arcade-pagination-engine.test.ts. Tests: three cases in arcade-pagination-engine.test.ts against a loopback `Bun.serve` engine (the prototype-stubbing suites are blind to this class — a hand-written generator serves the whole fixture whatever the caller asked for): the probe's split-provider listing now leaves the toolkit unanswered with zero deletes spent, the provider read asks `limit=100&offset=0` then `offset=100`, and a listing truncated at 250 of 300 refuses rather than answering off a prefix. All three failed before the fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/arcade-adapter.ts | 73 ++++++++------ server/tests/arcade-pagination-engine.test.ts | 95 ++++++++++++++++++- 2 files changed, 137 insertions(+), 31 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 354c2d8f5..e3a98786a 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -604,8 +604,9 @@ async function authProviderInstances( * AN UNRESOLVED TOOLKIT (`null`) ANSWERS AS NO ROWS and does NOT fall back to the toolkit name: a * fallback would silently restore the defect under a different spelling, invisibly against every * fixture in the tree. Callers then answer `isConnected` false, `revoke` false (no delete spent), - * `accountName` null. A malformed listing is different: `authProviderOf` and - * {@link authProviderInstances} both THROW, and that propagates, onto the Disconnect path included. + * `accountName` null. A listing that could not be read WHOLE is different: `authProviderOf` and + * {@link authProviderInstances} both THROW on one that is malformed, truncated against its own + * reported total, or never-ending, and that propagates, onto the Disconnect path included. */ async function connectionsFor( client: Arcade, @@ -883,24 +884,38 @@ async function grantsFor( * only one, and would be invisible against every fixture in this tree, where `Github`, `Asana` and * `Attio` all happen to be named after themselves. There is NO FALLBACK to the toolkit name. * - * ONE PAGE AND NO PAGING, which is what makes this free: every tool in a toolkit shares its auth - * provider — that is what makes it a toolkit. `listApps` pages to the end because it is COUNTING - * actions; this is reading a per-toolkit fact off any of them. + * PAGED TO ITS END THROUGH {@link pagedItems}, LIKE EVERY OTHER LISTING IN THIS FILE. An earlier + * revision read ONE unpaged request here and argued the saving was free: every tool in a toolkit + * shares its auth provider, so a further page could only repeat what the first already said. THAT + * ARGUMENT IS DISPROVEN, measured against a real HTTP engine — a 150-action `Gmail` listing whose + * first 100 actions sit under `google` and whose last 50 under `microsoft` answered `google`, off + * the single request `/v1/tools?toolkit=Gmail`. The guard below could not fire, because it was + * reading a PREFIX and a prefix agrees with itself. "Every tool shares its provider" is a statement + * about what a toolkit ought to be, not about what the vendor sends, and this function's whole job + * is to read what the vendor sends. The request also carried NO `limit`, so the window it judged + * from was the vendor's default rather than {@link CATALOGUE_PAGE_SIZE} — a number this deployment + * never chose and the vendor may change. * - * IT IS THE ONLY LISTING IN THIS FILE LEFT UNPAGED, DELIBERATELY. `grantsFor` and `connectionsFor` - * were paged because each answers ONE THING PER ROW, so a first page of theirs is a partial answer - * wearing a whole one's clothes. This answers one thing for the WHOLE TOOLKIT, so a further page - * can only repeat what the first already said — and the cost of paging it would be paid on every - * `isConnected`, `accountName` and `revoke`. A toolkit whose later pages disagreed with its first - * would be resolved here rather than unanswered; that is the known edge of this choice, and it is - * the vendor contradicting what a toolkit is. + * The cost is one extra request only on a toolkit larger than a page, and it buys the same property + * `grantsFor` and `connectionsFor` are paged for: a first page can never present as the whole of + * one. A TRUNCATED LISTING NOW THROWS here too, rather than resolving a provider off whatever + * arrived — the `incomplete` sentence below — which is right for the reason `connectionsFor`'s + * comment gives: these three callers spend deletes. * - * AND IT GUARDS RATHER THAN ASSUMES. The DISTINCT non-null provider ids on the page are collected - * and anything other than exactly one answers `null`, so a toolkit that disagrees with itself is - * UNANSWERED rather than silently resolved to whichever action came first. Case is carried through - * untouched, so two spellings of one provider count as two and are likewise unanswered; whether - * they are the same provider is the caller's comparison, and `connectionSiblings` folds case for - * the one caller that makes it. + * AND IT GUARDS RATHER THAN ASSUMES. The DISTINCT non-null provider ids across the WHOLE listing + * are collected and anything other than exactly one answers `null`, so a toolkit that disagrees + * with itself is UNANSWERED rather than silently resolved to whichever action came first. Case is + * carried through untouched, so two spellings of one provider count as two and are likewise + * unanswered; whether they are the same provider is the caller's comparison, and + * `connectionSiblings` folds case for the one caller that makes it. + * + * `null` IS WHAT UNANSWERED MEANS, AND IT ALREADY MEANT IT. It is not "no provider": the sole + * caller, {@link connectionsFor}, answers NO ROWS for it and never falls back to the toolkit name, + * so `isConnected` reads false, `accountName` null and `revoke` false WITHOUT SPENDING A DELETE. + * That is fail-closed on all three — nothing is deleted and nothing is claimed connected on a + * toolkit nobody can attribute — which is exactly what an ambiguous toolkit deserves, so there is + * no second spelling of "unanswered" to invent. A toolkit with no provider id anywhere lands on the + * same answer for the same reason. * * `requirements.met` IS NOT READ HERE EITHER, for `grantStateOf`'s reason: this reads a DIFFERENT * field of the same `requirements.authorization` object. @@ -909,17 +924,19 @@ async function authProviderOf( client: Arcade, toolkit: string, ): Promise { - const page = await vendorCall(`the action listing for ${toolkit}`, () => - client.tools.list({ toolkit }), - ); - const items = sentItemsOf(page); - if (!Array.isArray(items)) { - throw new Error( - `Arcade did not answer with an action list for ${toolkit}, so the auth provider its connections are held under could not be read.`, - ); - } const named = new Set(); - for (const item of items) { + for await (const item of pagedItems({ + pageSize: CATALOGUE_PAGE_SIZE, + maxPages: CATALOGUE_MAX_PAGES, + page: (offset) => + vendorCall(`the action listing for ${toolkit}`, () => + client.tools.list({ toolkit, limit: CATALOGUE_PAGE_SIZE, offset }), + ), + malformed: `Arcade did not answer with an action list for ${toolkit}, so the auth provider its connections are held under could not be read.`, + unending: `Arcade's action list for ${toolkit} never ended after ${CATALOGUE_MAX_PAGES} pages, so the auth provider its connections are held under is not trusted.`, + incomplete: (seen, total) => + `Arcade listed ${seen} of ${total} actions for ${toolkit}, so the auth provider its connections are held under could not be read in full.`, + })) { const authorization = record(record(item)?.requirements)?.authorization; const provider = textOf(record(authorization)?.provider_id); if (provider) named.add(provider); diff --git a/server/tests/arcade-pagination-engine.test.ts b/server/tests/arcade-pagination-engine.test.ts index 49c84afd1..565a60a5c 100644 --- a/server/tests/arcade-pagination-engine.test.ts +++ b/server/tests/arcade-pagination-engine.test.ts @@ -58,6 +58,15 @@ type EngineOptions = { * the engine answers the single-config listing the other tests here want. */ authProviders?: number; + /** + * The action listing DISAGREES WITH ITSELF ABOUT ITS AUTH PROVIDER from this index on: actions + * before it carry `requirements.authorization.provider_id: "google"`, actions from here on carry + * {@link toolLaterProvider}. Set past the first page, it is invisible to any reader that stops + * at page one. + */ + toolProviderSplitAt?: number; + /** The provider id the actions from {@link toolProviderSplitAt} on carry. */ + toolLaterProvider?: string; }; type Engine = { @@ -80,16 +89,23 @@ const connectionRow = (n: number, status: string | null = "active") => ({ user_id: account.vendorUserId, }); -const toolRow = (toolkit: string, n: number) => ({ +const toolRow = (toolkit: string, n: number, provider = "google") => ({ qualified_name: `${toolkit}.Action${n}`, description: "", toolkit: { name: toolkit, description: "" }, input: { parameters: [] }, requirements: { - authorization: { provider_id: "google", oauth2: { scopes: [] } }, + authorization: { provider_id: provider, oauth2: { scopes: [] } }, }, }); +/** The provider id action `n` of the listing carries, per {@link EngineOptions.toolProviderSplitAt}. */ +function toolProviderOf(options: EngineOptions, n: number): string { + const split = options.toolProviderSplitAt; + if (split === undefined || n < split) return "google"; + return options.toolLaterProvider ?? "microsoft"; +} + /** * The project's auth-provider configurations, with the ONE that matters LAST. * @@ -179,7 +195,7 @@ function startEngine(options: EngineOptions): Engine { const toolsServed = options.toolsServed ?? claimed; const page: unknown[] = []; for (let n = offset; n < Math.min(offset + limit, toolsServed); n++) { - page.push(toolRow(toolkit, n)); + page.push(toolRow(toolkit, n, toolProviderOf(options, n))); } const body: Record = { items: page, @@ -488,3 +504,76 @@ describe("the action listing's total_count cross-check survives a short final pa ); }); }); + +/** + * THE ACTION LISTING BEHIND THE AUTH-PROVIDER READ IS PAGED TOO, AND ONLY A REAL ENGINE ASKS IT TO. + * + * `authProviderOf` read ONE request with no `limit`, no `offset` and no loop, on the argument that + * every tool in a toolkit shares its auth provider, so a further page could only repeat the first. + * Against a real engine that is false in exactly the way that matters: a 150-action `Gmail` listing + * whose first 100 actions sit under `google` and whose last 50 sit under `microsoft` answered + * `google` off one unpaged request — CONFIDENTLY, from a window the VENDOR chose, since the request + * carried no `limit` at all. The function's own guard (the distinct provider ids, anything but + * exactly one answering unanswered) was reading a prefix, so it could never fire. + * + * It cannot be seen by stubbing the SDK's prototypes: a hand-written generator serves the whole + * fixture whatever the caller asked for, so the missing `limit`/`offset` and the missing loop both + * vanish into the stub. + */ +describe("the auth provider is read off the WHOLE action listing", () => { + test("a toolkit whose later pages name another provider is left unanswered", async () => { + const e = engine({ + connections: 1, + connectionTotal: false, + toolsClaimed: 150, + toolsServed: 150, + // The first page is uniformly `google`; the short second page is `microsoft`. + toolProviderSplitAt: 100, + }); + const { broker } = createArcadeClient("never-dialled"); + + // UNANSWERED, which the three callers already spell fail-closed: no rows, so not connected, no + // name, and `revoke` answers false WITHOUT SPENDING A DELETE. + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(false); + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe(null); + expect(await broker.revoke({ account, toolkit: "Gmail" })).toBe(false); + expect(deletes(e).length).toBe(0); + }); + + test("the provider read asks for pages by limit and offset", async () => { + const e = engine({ + connections: 1, + connectionTotal: false, + toolsClaimed: 150, + toolsServed: 150, + }); + const { broker } = createArcadeClient("never-dialled"); + + // One provider across all 150 actions, so the toolkit still resolves and the person reads as + // connected — but it took two requests, and the window is this file's own page size rather than + // whatever the vendor happens to default to. + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + expect(e.requests.filter((r) => r.startsWith("/v1/tools"))).toEqual([ + "/v1/tools?toolkit=Gmail&limit=100&offset=0", + "/v1/tools?toolkit=Gmail&limit=100&offset=100", + ]); + }); + + test("a truncated action listing refuses rather than answering a provider off a prefix", async () => { + const e = engine({ + connections: 1, + connectionTotal: false, + toolsClaimed: 300, + toolsServed: 250, + }); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/listed 250 of 300 actions for Gmail/); + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + /listed 250 of 300 actions for Gmail/, + ); + expect(deletes(e).length).toBe(0); + }); +}); From 8d75881ef217e97579a99ef5f0fa8599e353445a Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:46:56 +0200 Subject: [PATCH 154/232] fix(plugins): read a stored holder/user-id pair as the account it names, or refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `store.ts` kept two readings of a stored `(holder, user_id)` pair of its own, after D4 removed the same coercion from `routes.ts`: - `removeServer`'s local `accountOf` was a ternary whose else-branch absorbed every pair it did not recognise into `{ holder: "deployment" }`, so a `person` row with no user id was REVOKED under the deployment's account — ending a connection nobody asked about while the person's stayed live. Its `!== null` guard also let `user_id = ''` through as a person ref belonging to nobody, which is the pair that is actually reachable: `brokered_connections_holder_check` tests `IS NOT NULL`, and `''` satisfies it. The trail's `owner` field repeated the same coercion a second time. - `accountsOn` minted `userId: row.userId ?? ""` — the quiet direction of the same silence. Every account it hands back goes straight to `disconnectBrokered` from the account-mode switch, so a row naming nobody reached a revoke and was filed with an `owner` of `""`. Both now read through `asAccountRef` (D4, `shared-accounts.ts`) and throw `PluginInvariantError` on its `null`. `removeServer` resolves the whole set BEFORE the first revoke, so the refusal cannot land part-way through a removal with accounts already withdrawn at the vendor and their rows still standing, and its audit `owner` is now taken off the same `AccountRef` the revoke was asked with. REACHABILITY, per site: `person` + NULL is unwritable (the table's CHECK), so that branch is unreachable through the table — which is exactly why it must refuse rather than coerce; `person` + `''` IS writable and is what the red below exercises on both paths. No CHECK was weakened and no migration added. SAME-PATTERN AUDIT of the whole of `store.ts` — every other place an account reference is built, and why each is already correct: - `:1660` / `:1672` (`resolveAccount`): built from the signed-in actor and the app row's `sharedVendorUserId`, not from a stored `(holder, user_id)` pair. - `:4322` (sibling trail) and `:8147` (`disconnectBrokered` trail): read `account.holder` off an already-valid `AccountRef`, correct by type. - `:5999` / `:6072` (`deploymentConnectionFor`, `sharedConnections`): the query filters `holder = 'deployment'`, and that holder carries no user id. - `:8343` (`retireConnectionsFor`): a person ref built from the offboarded person's own id, which is a parameter and not a stored column. - `:1310` (`pendingAuthorizationFromRow`): carries the raw columns through as columns; its reading into an `AccountRef` is D4's, in `routes.ts`. CALL SITES of the symbols this touches: - `asAccountRef` (now imported by `store.ts`; unchanged): `routes.ts:3172`, `store.ts` `removeServer`, `store.ts` `accountsOn`. - `accountOf` (local arrow in `removeServer`): REMOVED; its three uses in that method now read the resolved `accounts` array. - `accountsOn` (signature unchanged, can now throw): `account-mode.ts:49` (`preview`), `account-mode.ts:200` (the switch), and the store tests `store-toolkit-dispatch`, `store-provider-required`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/store.ts | 100 +++++++------ .../store-account-ref.integration.test.ts | 136 ++++++++++++++++++ 2 files changed, 194 insertions(+), 42 deletions(-) create mode 100644 server/tests/store-account-ref.integration.test.ts diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5a684e793..c847b4ed6 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -88,6 +88,7 @@ import { type AccountMode, type AccountRef, accountFor, + asAccountRef, } from "./shared-accounts"; import type { SharedUseGate } from "./shared-use-store"; import { transportFor } from "./transport"; @@ -4152,20 +4153,32 @@ export function createPluginStore(options: PluginStoreOptions) { * which may still be standing after a switch back that has not retired it yet. Each is * revoked as the account its row says it is, under the vendor id the row was filed under, * so the deployment's account is asked for as the deployment's and never as whoever - * pressed "remove". A person row always carries its user id (the table's holder check), - * so the fallback to the deployment form below is the shape a corrupt row would need, not - * a reading anything here relies on. + * pressed "remove". + * + * READ THROUGH {@link asAccountRef}, AND A ROW THAT NAMES NO ACCOUNT STOPS THE REMOVAL + * BEFORE ANYTHING IS ASKED OR DELETED. This was a ternary whose else-branch absorbed every + * pair it did not recognise into `{ holder: "deployment" }`, and the two readings below — + * the revoke and the trail's `owner` — each repeated it. `brokered_connections_holder_check` + * makes `person` with a NULL user id unwritable, which is exactly why this is a refusal + * rather than a coercion: a state the database says cannot exist, absorbed into the other + * holder, is a withdrawal aimed at the DEPLOYMENT's account over a person's row. The + * EMPTY STRING is not hypothetical — `IS NOT NULL` does not stop `''` — and the old + * reading's `!== null` let it through as a person ref under nobody's id, which reached + * `rowBroker.revoke` and was filed as an `owner` of `""`. + * + * RESOLVED FOR THE WHOLE SET UP FRONT, so the throw lands before the first revoke rather + * than part-way through one: a removal that had already withdrawn two accounts at the + * vendor and then threw would leave their rows standing and no trail for either. */ - const accountOf = ( - connection: (typeof connected)[number], - ): AccountRef => - connection.holder === "person" && connection.userId !== null - ? { - holder: "person", - userId: connection.userId, - vendorUserId: connection.vendorUserId, - } - : { holder: "deployment", vendorUserId: connection.vendorUserId }; + const accounts = connected.map((connection): AccountRef => { + const account = asAccountRef(connection); + if (!account) { + throw new PluginInvariantError( + `${toolkit} holds a connection row whose holder and user id name no account, so the accounts standing for it cannot be ended.`, + ); + } + return account; + }); /* * What the broker was actually asked for each of them, kept so the trail below records the @@ -4197,7 +4210,7 @@ export function createPluginStore(options: PluginStoreOptions) { * {@link resolveSiblings} for why one call's worth is semantically free. */ const authProviders = new Map>(); - for (const connection of connected) { + for (const account of accounts) { /* * ASKED OF `rowBroker` AND NOT OF `broker`, both here and at the revoke below. * {@link resolveSiblings} resolves against the CONFIGURED provider's rows and the active @@ -4207,19 +4220,11 @@ export function createPluginStore(options: PluginStoreOptions) { */ endedBesides.push( rowBroker - ? await resolveSiblings( - { toolkit, account: accountOf(connection) }, - authProviders, - ) + ? await resolveSiblings({ toolkit, account }, authProviders) : { siblings: [], unanswered: 0 }, ); vendorRevocationRequested.push( - rowBroker - ? await rowBroker.revoke({ - account: accountOf(connection), - toolkit, - }) - : false, + rowBroker ? await rowBroker.revoke({ account, toolkit }) : false, ); } @@ -4231,7 +4236,7 @@ export function createPluginStore(options: PluginStoreOptions) { ), ); - for (const [index, connection] of connected.entries()) { + for (const [index, account] of accounts.entries()) { await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", @@ -4260,11 +4265,11 @@ export function createPluginStore(options: PluginStoreOptions) { server: toolkit, // Whose account this was, by the rule {@link disconnectBrokered} names it by: a // person's own under their id, the deployment's own as the deployment, never as - // the administrator who removed the app. + // the administrator who removed the app. Taken off the SAME `AccountRef` the revoke + // above was asked with — read once, by `asAccountRef` — so the trail cannot name a + // holder the withdrawal did not use. owner: - connection.holder === "person" && connection.userId !== null - ? connection.userId - : "deployment", + account.holder === "person" ? account.userId : "deployment", // The same three-way distinction the vault loop above draws, and the same answer: an // administrator took the whole app away and the person did nothing. reason: "mcp_server_removed", @@ -4298,13 +4303,12 @@ export function createPluginStore(options: PluginStoreOptions) { * siblings it deletes: `true` here requires `rowBroker`, and there is no `rowBroker` for a * row the other broker minted. */ - for (const [index, connection] of connected.entries()) { + for (const [index, account] of accounts.entries()) { const { siblings, unanswered } = endedBesides[index] ?? { siblings: [], unanswered: 0, }; if (!vendorRevocationRequested[index]) continue; - const account = accountOf(connection); for (const app of siblings) { const [deleted] = await database .delete(brokeredConnections) @@ -5031,7 +5035,20 @@ export function createPluginStore(options: PluginStoreOptions) { return rows.map((row) => row.url); }, - /** Every account any holder has on a brokered app, as the broker would be asked to end it. */ + /** + * Every account any holder has on a brokered app, as the broker would be asked to end it. + * + * READ THROUGH {@link asAccountRef}, AND A ROW THAT NAMES NO ACCOUNT STOPS THE READ. Every + * account handed back here is passed straight to {@link disconnectBrokered} by the mode + * switch, so what is minted here is what a revoke is aimed at and what the trail records. The + * reading this replaces was `userId: row.userId ?? ""`, the quiet direction of the same + * coercion `asAccountRef` exists to refuse: a person row carrying no user id came out as a + * person ref belonging to NOBODY and was withdrawn under an owner of `""`. `''` is the pair + * that is actually reachable — `brokered_connections_holder_check` tests `IS NOT NULL`, which + * `''` satisfies — and `null` is unwritable, which is why both are a refusal rather than a + * default. The `null` answer below still means only "not a brokered app at this broker"; + * a row that cannot be read is this deployment contradicting its own CHECK, and says so. + */ async accountsOn( serverId: string, ): Promise<{ toolkit: string; accounts: AccountRef[] } | null> { @@ -5057,16 +5074,15 @@ export function createPluginStore(options: PluginStoreOptions) { ); return { toolkit, - accounts: held.map( - (row): AccountRef => - row.holder === "person" - ? { - holder: "person", - userId: row.userId ?? "", - vendorUserId: row.vendorUserId, - } - : { holder: "deployment", vendorUserId: row.vendorUserId }, - ), + accounts: held.map((row): AccountRef => { + const account = asAccountRef(row); + if (!account) { + throw new PluginInvariantError( + `${toolkit} holds a connection row whose holder and user id name no account, so the accounts standing for it cannot be listed.`, + ); + } + return account; + }), }; }, diff --git a/server/tests/store-account-ref.integration.test.ts b/server/tests/store-account-ref.integration.test.ts new file mode 100644 index 000000000..e4b901db5 --- /dev/null +++ b/server/tests/store-account-ref.integration.test.ts @@ -0,0 +1,136 @@ +// server/tests/store-account-ref.integration.test.ts +// +// THE STORE READS A STORED `(holder, user_id)` PAIR AS THE ACCOUNT IT NAMES, OR REFUSES. +// +// `asAccountRef` is the one reading, and its point is the `null`: a row that names no account this +// deployment may act in must stop the act rather than be rebuilt as some other account. `store.ts` +// had two readings of its own — one that absorbed an unreadable pair into `{ holder: "deployment" }` +// and one that minted `{ holder: "person", userId: "" }` — and both of them fed a REVOKE and an +// audit row. `brokered_connections_holder_check` is `(person AND user_id IS NOT NULL) OR +// (deployment AND user_id IS NULL)`, and `''` satisfies `IS NOT NULL`, so the person-with-no-user +// row IS writable and is what both tests below seed. +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { createDatabase } from "../src/db/client"; +import { brokeredConnections, mcpServers } from "../src/db/schema"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import type { AccountRef } from "../src/plugins/shared-accounts"; +import { createPluginStore, PluginInvariantError } from "../src/plugins/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const suite = randomUUID().slice(0, 8); +const app = `nobody-gh-${suite}`; + +/** Every account the broker was ASKED to end, whole, so a wrong holder is visible in the failure. */ +const asked: AccountRef[] = []; +const broker = { + grantIsLocal: true, + authProviderOf: async () => null, + authorizations: null, + grantsFor: async () => [], + listApps: async () => [], + ensureAuthConfig: async () => "standing", + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }), + isConnected: async () => true, + revoke: async ({ account }: { account: AccountRef }) => { + asked.push(account); + return true; + }, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => "acme-bot", +} as unknown as ConnectedAppBroker; + +function freshStore() { + return createPluginStore({ + brokerProvider: "composio", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker, + deploymentId: `acme-${suite}`, + }); +} + +/** A person row carrying no user id — written as `''`, which the table's CHECK does not stop. */ +async function seedPersonWithNoUser() { + await database.insert(mcpServers).values({ + id: app, + title: "Team GitHub", + vendor: "Composio", + url: `composio://${app}`, + provenance: "composio", + authScheme: "OAUTH2", + }); + await database.insert(brokeredConnections).values({ + provider: "composio", + app, + holder: "person", + userId: "", + vendorUserId: `vendor-${suite}`, + }); +} + +afterEach(async () => { + asked.length = 0; + await database + .delete(brokeredConnections) + .where(eq(brokeredConnections.app, app)); + await database.delete(mcpServers).where(inArray(mcpServers.id, [app])); +}); + +describe("a stored pair that names nobody", () => { + test("stops `removeServer` before the vendor is asked to end anybody's account", async () => { + const store = freshStore(); + await seedPersonWithNoUser(); + + const outcome = await store.removeServer(app, "admin@example.test").then( + () => "removed", + (error: unknown) => error, + ); + + expect(asked).toEqual([]); + expect(outcome).toBeInstanceOf(PluginInvariantError); + const rows = await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where(eq(brokeredConnections.app, app)); + expect(rows).toHaveLength(1); + }); + + test("stops the mode switch's read before it hands the account to a revoke", async () => { + const store = freshStore(); + await seedPersonWithNoUser(); + + // The sequence `account-mode.ts` runs: read every account on the app, then end each one. + const outcome = await (async () => { + const on = await store.accountsOn(app); + if (!on) return "not brokered"; + for (const account of on.accounts) { + await store.disconnectBrokered({ + toolkit: on.toolkit, + account, + by: "admin@example.test", + reason: "mode_switched", + }); + } + return "ended"; + })().then( + (answer) => answer, + (error: unknown) => error, + ); + + expect(asked).toEqual([]); + expect(outcome).toBeInstanceOf(PluginInvariantError); + }); +}); From 922d4ecb44f324b257965776aedaf67f84b7094d Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:50:27 +0200 Subject: [PATCH 155/232] fix(test): type arcade-verify's broker bag as a BrokerProvider, not a string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the orchestrator while verifying wave 3, NOT by any gate — which is the point. `server/tests` is outside `tsc`, so `bun run typecheck` is green on this file and CI cannot see the error at all. An isolated tsc over it reports: tests/arcade-verify.test.ts(223,3): error TS2322: Type '{ broker: ConnectedAppBroker; provider: string } | undefined' is not assignable to '{ broker: ConnectedAppBroker; provider: BrokerProvider } | undefined' The file is new in this branch, so this PR introduced it. `brokerBag` declared its provider as a bare `string` while the app's parameter wants the two-member union, so a stand-in bag could have named a vendor that does not exist and nothing would have objected. This is the same class the ledger already records as bucket (d) 'type-check-server-tests' — the gap that hid the merge defect fixed in 75614fb2. The class stays filed as follow-up; this instance is ours and is fixed here. Evidence: isolated tsc over the file now clean; arcade-verify 37 pass / 0 fail; bun run typecheck 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/tests/arcade-verify.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index f2578da3f..eb66cff5e 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -34,6 +34,7 @@ import { mcpServers, pendingAuthorizations, } from "../src/db/schema"; +import type { BrokerProvider } from "../src/plugins/access"; import type { ConnectedAppBroker } from "../src/plugins/broker"; import { AUTHORIZATION_REFUSALS } from "../src/plugins/routes"; import type { AccountRef } from "../src/plugins/shared-accounts"; @@ -147,7 +148,9 @@ function build( who: string | null, options: { roles?: string[]; - brokerBag?: { broker: ConnectedAppBroker; provider: string } | undefined; + brokerBag?: + | { broker: ConnectedAppBroker; provider: BrokerProvider } + | undefined; /** A handle whose behaviour one case alters; everything else runs on the suite's own. */ database?: typeof database; /** From 419ce7097734bf7f70a6053e8a7e76e29a3958e7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 13 Oct 2026 23:51:58 +0200 Subject: [PATCH 156/232] fix(plugins): account for the apps a retired identity's revoke also ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G3. The verifier refuses a consent whose context has moved on and withdraws the RETIRED identity's app at the vendor. On Arcade a connection is keyed `(identity, auth provider)`, so that single revoke ends every app of that identity sharing the provider — and this deployment's rows for those siblings were left standing. The records then claimed live connections for apps the vendor had already disconnected: a person sees apps listed as connected that fail on first use, and nothing here knew to tell them otherwise. WHAT MUST BE TRUE AFTERWARDS decides the shape. For a person: nothing here says "connected" about a connection that is gone. For an operator: every app that lost its connection has a trail row naming what ended it, and no row says `vendorRevocationRequested: false` — "no vendor was asked" — about an account a vendor withdrew. So the route resolves and clears rather than refuses: refusing would leave a possibly-live grant bound to a retired identity AND leave the siblings' rows lying either way. The route now resolves the siblings through `store.siblingApps` BEFORE the revoke goes out (G2's ordering, taken from `removeServer`/`disconnectBrokered` — after the revoke, a sibling's auth provider is a question about a connection that is already gone), and on an answer of TRUE — and only then, G2's rule — clears the retired identity's rows for the asked app and its siblings and records each. A sibling's row carries `endedWith` and `unansweredSiblings` beside it; the asked app's names no other app. `false` claims nothing and clears nothing. A sibling set that could not be resolved stops the revoke rather than widening it: ending apps this deployment cannot then name is the failure itself, and the attempt is kept `unreconciled`, which is a row an operator can act on. G1 is kept: only a row's own provenance decides what is cleared (a key-scheme app is skipped entirely — no revoke reaches a key this deployment holds, so deleting its row would destroy a credential nothing withdrew), and the trail word is `context_changed` rather than the nearest-looking `mode_switched`, since a mode switch is only one of the ways a context moves. Same-pattern audit of routes.ts: this was the ONLY vendor revocation issued from the file (`composio.broker.revoke`, one site). `store.revoke` is plugin grants, not vendor connections; `DELETE /servers/:id/connection` and the server removal go through `disconnectBrokered`/`removeServer`, which already account for siblings. Call sites of the symbols touched: - `PluginStore.clearWithdrawnAccount` (new): one caller, the context-changed branch of `GET /api/plugins/arcade/verify` in server/src/plugins/routes.ts. No other caller; `reason` is typed to the single literal "context_changed" so a second caller cannot spell it differently. - `PluginStore.siblingApps` (existing, unchanged): callers are `GET /servers/:id/connection/siblings` and now the verify route. - arcade-verify.test.ts's broker stub `authProviderOf` now forwards the toolkit to `authProviderAnswer`, whose type gains that parameter; every existing assignment ignores it and is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsS7GKpBgupvAKampCVKAK --- server/src/plugins/routes.ts | 53 ++++++++ server/src/plugins/store.ts | 102 ++++++++++++++++ server/tests/arcade-verify.test.ts | 187 ++++++++++++++++++++++++++++- 3 files changed, 339 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index a91cf7678..e702e1a9d 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3294,7 +3294,42 @@ export function createPluginRoutes( const row = await store.serverAddress(attempt.serverId); const retiredApp = row ? appOfUrl(row.url) : null; let withdrawn = false; + /* + * WHAT THIS WITHDRAWAL ENDS BESIDES THE APP IT NAMES, RESOLVED BEFORE THE REVOKE GOES OUT. + * + * Where a connection is keyed `(identity, auth provider)` — Arcade — the one revoke below + * ends the whole connection and so every app on the retired identity that shares it. Their + * rows here would be left standing, which makes this deployment's own records claim live + * connections for apps the vendor has already disconnected: a person sees apps listed as + * connected that fail on first use, and nothing here knows to tell them otherwise. The set + * is the store's, keyed on the pair and never on the provider alone, and BEFORE rather than + * after, which is the ordering `disconnectBrokered` and `removeServer` both keep: once the + * revoke has run, a sibling's auth provider is a question asked of a connection that is + * already gone. On Composio an app is its own auth provider, so it is always empty and + * nothing below differs from what this refusal always did. + * + * AND A SET THIS COULD NOT RESOLVE STOPS THE REVOKE RATHER THAN WIDENING IT. The ask is a + * live vendor call per app on a request a browser is held open on, so it is bounded — on + * the WHOLE resolution rather than on each call inside it, because the store's method takes + * no signal, which is the bound that matters to the browser. A timeout or a refusal leaves + * `collateral` null and nothing is then asked of the vendor at all. Ending apps this + * deployment cannot then name is the failure this whole branch is about, and the + * alternative is not silence: the attempt is kept `unreconciled` below, which is a row an + * operator can act on over a grant that is merely still live. + */ + let collateral: { siblings: string[]; unanswered: number } | null = + null; if (retiredApp) { + try { + collateral = await withVendorDeadline(() => + store.siblingApps({ toolkit: retiredApp, account: retired }), + ); + } catch { + // Nothing from the vendor's answer travels with this, here as everywhere on this leg. + collateral = null; + } + } + if (retiredApp && collateral) { try { /* * THE BOOLEAN IS READ, NOT DROPPED. `false` used to mean only "nothing there", but the @@ -3313,6 +3348,24 @@ export function createPluginRoutes( withdrawn = false; } } + /* + * AND THE ROWS FOR WHAT IT ENDED GO WITH IT, ONLY ON AN ANSWER OF TRUE. `false` is "nothing + * found, or we could not work out what to withdraw", and no local row may be cleared — nor + * any collateral claimed on the trail — on the strength of an answer nobody got. The trail + * rows this writes are what tell a person why an app they never touched stopped being + * connected here, and tell an operator that one withdrawal took several apps with it. + */ + if (withdrawn && retiredApp && collateral) { + await store.clearWithdrawnAccount({ + toolkit: retiredApp, + account: retired, + siblings: collateral.siblings, + unanswered: collateral.unanswered, + // The actor this attempt is keyed on, read once at the top like everything else here. + by: actorId, + reason: "context_changed", + }); + } if (withdrawn) await store.markAuthorizationWithdrawn({ actorId, authId }); else await store.markAuthorizationUnreconciled({ actorId, authId }); diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5a684e793..7b4bc3c58 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -8167,6 +8167,108 @@ export function createPluginStore(options: PluginStoreOptions) { return { vendorRevocationRequested }; }, + /** + * THE LOCAL SIDE OF A WITHDRAWAL SOMEBODY ELSE ALREADY MADE AT THE VENDOR: the rows this + * deployment holds for an account a revoke has just ended, and the trail that says why they + * went. + * + * WHO THIS IS FOR. The verifier refuses a consent whose context moved on and withdraws the + * RETIRED identity's app at the vendor — a revoke it issues itself, outside this store, + * because the decision is the route's and the attempt row is what records it. On Arcade that + * one revoke ends the whole `(identity, auth provider)` connection and so every app sharing + * it, and the rows here for those apps would otherwise be left standing: this deployment's own + * records claiming live connections for apps the vendor has already disconnected. A person + * then sees apps listed as connected that fail on first use, and nothing tells them otherwise. + * This is the write that stops that, and it is a STORE method rather than route code for the + * reason every other row write is: `brokered_connections` and the trail are this file's. + * + * IT ASKS NOTHING OF ANY VENDOR AND DECIDES NOTHING BUT WHAT A LOCAL ROW SAYS. The caller + * resolved the siblings — of + * {@link siblingApps}, BEFORE its revoke went out, which is the ordering {@link + * disconnectBrokered} and {@link removeServer} both keep — and the caller watched its revoke + * ANSWER TRUE. Called on anything less, it would be claiming an account gone on the strength + * of an answer nobody got; that rule is {@link removeServer}'s and this one is its other half. + * + * ONLY A ROW THAT WAS ACTUALLY HERE GETS A TRAIL ROW (`if (!deleted) continue`), which is + * again {@link removeServer}'s rule. The act itself is already recorded — on the attempt row, + * as `withdrawn` — so these rows exist to explain why an app's connection VANISHED from this + * deployment's records, and an app that had no row here had nothing to explain. + * + * `reason` IS ONE WORD AND IT IS THIS ONE. "The consent's context moved on" is not any of the + * four {@link disconnectBrokered} takes — nobody disconnected anything, nobody was offboarded, + * and a mode switch is only one of the several ways the context can move — so the trail gets + * its own word rather than the nearest-looking lie. Fixed in the type so there is no second + * caller spelling it differently. + */ + async clearWithdrawnAccount(input: { + toolkit: string; + account: AccountRef; + /** The apps the caller's revoke ended besides `toolkit`; {@link siblingApps} answers it. */ + siblings: string[]; + /** + * How many apps on this identity could not be asked about, an integer and never names, which + * qualifies the `endedWith` claim and goes beside it and nowhere else; see + * {@link resolveSiblings}. + */ + unanswered: number; + by: string; + reason: "context_changed"; + }): Promise<{ cleared: string[] }> { + const cleared: string[] = []; + /* + * The asked app first, then the apps that went with it, so the trail reads in the order the + * withdrawal happened. The asked app's own row names no other app beside it — it was ended + * by its own ask — and a sibling's names the app that asked, which is the only thing that + * tells a reader why a connection they never touched is gone. + */ + for (const app of new Set([input.toolkit, ...input.siblings])) { + /* + * AN APP THIS DEPLOYMENT HOLDS A KEY FOR IS NOT WHAT THAT REVOKE ENDED, so its row stands + * and the trail says nothing about it. The withdrawal ended an OAuth connection; a key + * sits in this deployment's own records and no revoke at the vendor reaches it. Deleting + * it would destroy a credential nothing withdrew, and recording it would claim a vendor + * ended something it never held — which is the reading {@link disconnectBrokered} writes + * `vendorRevocationRequested: false` for, there on an act that really was about that app. + * The scheme is read off the row while the row is still here, and it is a local read. + */ + if ((await brokeredAppKind(app)) === "key") continue; + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(app, input.account)) + .returning({ app: brokeredConnections.app }); + if (!deleted) continue; + cleared.push(app); + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: app, + payload: { + actor: input.by, + server: app, + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + /* + * TRUE FOR EVERY ROW HERE, AND IT IS NOT A GUESS: this method is reached only from a + * revoke that answered true, and on Arcade that ask ended the siblings with the app it + * named. `vendorRevocationRequested: false` means "no vendor was asked", which is the + * one thing none of these rows may say. + */ + vendorRevocationRequested: true, + ...(app === input.toolkit + ? {} + : { + endedWith: input.toolkit, + unansweredSiblings: input.unanswered, + }), + }, + }); + } + return { cleared }; + }, + /** * Retire every connector credential belonging to one person. * diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index f2578da3f..ce67130b8 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -22,7 +22,7 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { getCookies } from "better-auth/cookies"; -import { and, eq, like } from "drizzle-orm"; +import { and, asc, eq, inArray, like, sql } from "drizzle-orm"; import { createApp } from "../src/app"; import { createAuditStore } from "../src/audit"; import { createAuth } from "../src/auth/index"; @@ -97,7 +97,8 @@ let revokeAnswer: () => Promise = async () => true; * only where an operator named their provider configuration after the app, so a route deriving one * from the other would pass here and be wrong where it shipped. */ -let authProviderAnswer: () => Promise = async () => "google"; +let authProviderAnswer: (toolkit: string) => Promise = + async () => "google"; /** * A broker that DECLARES the correlated authorization flow, which is the only thing this route's @@ -106,7 +107,13 @@ let authProviderAnswer: () => Promise = async () => "google"; */ const broker: ConnectedAppBroker = { grantIsLocal: false, - authProviderOf: async () => authProviderAnswer(), + /* + * PER TOOLKIT, because which apps share one connection is the question `(identity, auth + * provider)` answers and a stub that gave every app the same provider could not pose it: an app + * on ANOTHER provider, held by the same identity and not ended by this revoke, is the control + * for the sibling cases below. + */ + authProviderOf: async (toolkit) => authProviderAnswer(toolkit), authorizations: { status: async (id) => { statusCalls.push(id); @@ -880,6 +887,52 @@ describe("a consent whose account context has moved on", () => { .where(eq(mcpServers.id, APP)); } + /** A connection this identity holds for one app, as the row this deployment keeps for it. */ + async function hold(app: string, vendorUserId: string) { + await database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder: "person", + userId: vendorUserId, + vendorUserId, + }); + } + + /** + * Which of these apps this deployment still records a connection for ON ONE IDENTITY, in name + * order. Keyed on the identity because the whole question here is which identity's rows went: + * the retired one's, and never the account that is live now. + */ + const rowsFor = async (names: string[], vendorUserId: string) => + ( + await database + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where( + and( + inArray(brokeredConnections.app, names), + eq(brokeredConnections.vendorUserId, vendorUserId), + ), + ) + .orderBy(asc(brokeredConnections.app)) + ).map((row) => row.app); + + /** What the trail says ended, by app, with the payload a reader would be answered with. */ + const disconnectsFor = (names: string[]) => + database + .select({ + app: auditEvents.targetId, + payload: sql>`payload`, + }) + .from(auditEvents) + .where( + and( + eq(auditEvents.eventType, "mcp.account_disconnected"), + inArray(auditEvents.targetId, names), + ), + ) + .orderBy(asc(auditEvents.targetId)); + test("refuses, withdraws the RETIRED identity's grant and leaves the CURRENT account alone", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); await switchToShared(); @@ -953,6 +1006,134 @@ describe("a consent whose account context has moved on", () => { expect(row?.authId).toBe(MINTED); await expectNothingWritten(); }); + + /* + * G3: THE ONE REVOKE ABOVE ENDS MORE THAN THE APP IT NAMES, AND THE ROWS HERE HAVE TO SAY SO. + * + * On Arcade a connection is keyed `(identity, auth provider)`, so withdrawing the retired + * identity's app ends every app of that identity sharing the provider. A sibling's row left + * standing is this deployment claiming a live connection for an app the vendor has already + * disconnected: the person sees it listed as connected and it fails on first use. + * + * The apps are named off this test's own suite-scoped toolkit, so the suite's `like` cleanup + * takes them and no other case can see them. + */ + const siblingApp = () => `${TOOLKIT}+calendar`; + const otherProviderApp = () => `${TOOLKIT}+slack`; + + test("the apps the same revoke ended lose their rows too, and the trail names what ended them", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + await giveCurrentAccountAConnection(); + // The retired identity holds the app this consent was for and two more: one on the SAME auth + // provider, which this revoke ends with it, and one on another, which it cannot reach. + await hold(TOOLKIT, ACTOR); + await hold(siblingApp(), ACTOR); + await hold(otherProviderApp(), ACTOR); + authProviderAnswer = async (toolkit) => + toolkit.endsWith("+slack") ? "slack" : "google"; + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "context-changed", + ); + + // STILL ONE ASK, AND STILL ONLY THE RETIRED IDENTITY'S: the siblings are what that ask ends, + // not more calls to make. + expect(revokeCalls).toEqual([ + { + account: { holder: "person", userId: ACTOR, vendorUserId: ACTOR }, + toolkit: TOOLKIT, + }, + ]); + + // The retired identity's two apps on the ended connection are gone from this deployment's + // records; the app on another provider is untouched. + expect( + await rowsFor([TOOLKIT, siblingApp(), otherProviderApp()], ACTOR), + ).toEqual([otherProviderApp()]); + + // AND THE CURRENT ACCOUNT'S ROW IS STILL THERE: a connection is keyed on the identity. + const { connections } = await written(); + expect(connections.length).toBe(1); + expect(connections[0]?.holder).toBe("deployment"); + expect(connections[0]?.vendorUserId).toBe(SHARED_IDENTITY); + + const trail = await disconnectsFor([ + TOOLKIT, + siblingApp(), + otherProviderApp(), + ]); + expect(trail.map((event) => event.app)).toEqual( + [TOOLKIT, siblingApp()].sort(), + ); + // The sibling's row names the app whose withdrawal ended it, and claims the vendor WAS asked: + // `vendorRevocationRequested: false` would say nobody asked about an account that is gone. + expect( + trail.find((event) => event.app === siblingApp())?.payload, + ).toMatchObject({ + reason: "context_changed", + endedWith: TOOLKIT, + unansweredSiblings: 0, + vendorRevocationRequested: true, + owner: ACTOR, + }); + // The asked app's own row names no other app beside it: it was ended by its own ask. + const asked = trail.find((event) => event.app === TOOLKIT)?.payload; + expect(asked).toMatchObject({ + reason: "context_changed", + vendorRevocationRequested: true, + owner: ACTOR, + }); + expect(asked).not.toHaveProperty("endedWith"); + + const row = await attemptRow(); + expect(row?.outcome).toBe("withdrawn"); + }); + + test("a revoke that answers false clears nothing and claims no collateral", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + await hold(TOOLKIT, ACTOR); + await hold(siblingApp(), ACTOR); + revokeAnswer = async () => false; + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "context-changed", + ); + + // "Nothing found, or we could not work out what to withdraw": no row may be cleared and no + // app claimed ended on the strength of an answer nobody got. + expect(await rowsFor([TOOLKIT, siblingApp()], ACTOR)).toEqual( + [TOOLKIT, siblingApp()].sort(), + ); + expect(await disconnectsFor([TOOLKIT, siblingApp()])).toEqual([]); + expect((await attemptRow())?.outcome).toBe("unreconciled"); + }); + + test("a sibling set this deployment could not resolve stops the revoke rather than widening it", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + await hold(TOOLKIT, ACTOR); + await hold(siblingApp(), ACTOR); + authProviderAnswer = async () => { + throw new Error("the vendor would not say which provider holds this"); + }; + + await expectRefusal( + await verify(ACTOR, "?flow_id=flow-abc"), + "context-changed", + ); + + // Ending apps this deployment cannot then name is the failure this is about, so nothing is + // asked of the vendor at all and the grant is left as a row somebody can act on. + expect(revokeCalls).toEqual([]); + expect(await rowsFor([TOOLKIT, siblingApp()], ACTOR)).toEqual( + [TOOLKIT, siblingApp()].sort(), + ); + expect((await attemptRow())?.outcome).toBe("unreconciled"); + }); }); /** From 727b0e13b35efb52053ba91cb106760bd15063eb Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 02:26:44 +0200 Subject: [PATCH 157/232] test(arcade): state revoke's outcome invariant over seven vendor response shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CL-5 of the convergence audit. Every existing test on the revoke seam pins a MECHANISM — the pager loops, the fallback returns the parsed body, the status allowlist refuses — and none states the OUTCOME those mechanisms exist to serve, so each new vendor response shape re-opens the same live defect through whichever mechanism does not yet cover it. This states the outcome once and asks it of seven shapes enumerated from round-2 findings: revoke never answers `false` without a DELETE spent at the vendor, and where the connection listing was unverifiable it throws rather than answering, per broker.ts:712-717. Strictly additive: one new test file, no production source touched. Four of the seven land RED and are the input to the next fix cycle. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-revoke-invariant.test.ts | 319 +++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 server/tests/arcade-revoke-invariant.test.ts diff --git a/server/tests/arcade-revoke-invariant.test.ts b/server/tests/arcade-revoke-invariant.test.ts new file mode 100644 index 000000000..b856443d6 --- /dev/null +++ b/server/tests/arcade-revoke-invariant.test.ts @@ -0,0 +1,319 @@ +// server/tests/arcade-revoke-invariant.test.ts +/** + * ONE OUTCOME INVARIANT ON `revoke`, STATED ONCE AND ASKED OF EVERY VENDOR RESPONSE SHAPE. + * + * The live defect this seam has already suffered — and which two review rounds have now reached by + * four separate routes — is always the same OUTCOME, never the same mechanism: + * + * `revoke` ANSWERS `false` WITH NO DELETE SPENT AT THE VENDOR, while the caller deletes the + * local `brokered_connections` row anyway — leaving a live grant at the vendor that no row + * this deployment holds any longer names. + * + * Every existing test on this seam pins a MECHANISM: "the pager loops", "the fallback returns the + * parsed body", "the status allowlist refuses". A mechanism test passes for every shape it was + * written against and says nothing about the next one, so each new vendor response shape re-opens + * the same defect through whichever mechanism does not yet cover it. This file pins the OUTCOME + * instead, so a shape nobody anticipated is caught by an assertion rather than in production. + * + * THE INVARIANT, from `broker.ts:712-717` ("A PARTIAL ASK IS A FAILURE RATHER THAN A TRUE … It + * must throw, so that the row this deployment holds … is still standing when they press disconnect + * a second time"): + * + * 1. `revoke` NEVER answers `false` without a DELETE having been spent at the vendor. `false` + * means "there was nothing to withdraw", and that is a claim about the vendor's side which + * only a listing read WHOLE can support. + * 2. Where the connection listing was UNVERIFIABLE — not readable as the whole of itself — it + * does not answer at all. It throws. An answer either way is a guess, and both guesses are + * destructive: `false` orphans a live grant, `true` reports a partial ask as a clean success. + * + * ALL SEVEN SHAPES BELOW ARE UNVERIFIABLE LISTINGS, so for all seven the required outcome is the + * same: THROW. None of them may answer. The seven are enumerated verbatim from round-2 findings + * and each reaches the listing through a different door, which is the point — the assertion does + * not know or care which door. + * + * SERVED OVER A REAL SOCKET, for `arcade-pagination-engine.test.ts`'s reason: the rest of the + * adapter suite stubs SDK resource PROTOTYPES, and `OffsetPage`'s constructor manufactures + * `items: []` and `total_count: 0` for fields the vendor never sent. A hand-built stub cannot pose + * any of these seven questions, because the shapes ARE what the wire carried. + * + * DRIVEN THROUGH THE BROKER SEAM — the public `ConnectedAppBroker.revoke` — and never through an + * adapter internal. The invariant is a property of the seam; a test that reached `connectionsFor` + * directly would be pinning a mechanism again. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +/** The seven shapes, each named by what the VENDOR SENT rather than by what breaks. */ +type Shape = + /** 1. a non-JSON 200 response to the connection listing. */ + | "listing-is-not-json" + /** 2. a 204 / `content-length: 0` response on a paged endpoint. */ + | "listing-is-204" + /** 3. a body whose JSON is the literal `null`. */ + | "listing-is-json-null" + /** 4. `items: [null]` on the user-connections listing. */ + | "listing-item-is-null" + /** 5. `total_count` sent as a JSON string rather than a number, on a TRUNCATED listing. */ + | "total-count-is-a-string" + /** 6. a toolkit whose actions name ZERO provider ids. */ + | "toolkit-names-no-provider" + /** 7. a toolkit whose actions name TWO OR MORE provider ids. */ + | "toolkit-names-two-providers"; + +const CONNECTION_PAGE_SIZE = 100; + +/** A live, attributable user-connection row: the thing a DELETE would be spent on. */ +const connectionRow = (n: number) => ({ + id: `at_${n}`, + connection_id: `ac_${n}`, + connection_status: "active", + // A provider INSTANCE id, never a provider type — see `connectionsFor`'s comment. + provider_id: "google-openbot", + provider_type: "oauth2", + provider_user_info: { name: `person ${n}` }, + scopes: [], + user_id: account.vendorUserId, +}); + +/** One listed action, carrying the provider ids `authProviderOf` reads off the listing itself. */ +const toolRow = (toolkit: string, n: number, providers: string[]) => ({ + qualified_name: `${toolkit}.Action${n}`, + description: "", + toolkit: { name: toolkit, description: "" }, + input: { parameters: [] }, + requirements: { + authorization: + providers.length === 0 + ? // ZERO provider ids: the authorization block is there, the id is not. + { oauth2: { scopes: [] } } + : { + provider_id: providers[n % providers.length], + oauth2: { scopes: [] }, + }, + }, +}); + +/** Which provider ids this shape's action listing names. */ +function providersFor(shape: Shape): string[] { + if (shape === "toolkit-names-no-provider") return []; + if (shape === "toolkit-names-two-providers") return ["google", "microsoft"]; + return ["google"]; +} + +type Engine = { port: number; requests: string[]; stop: () => void }; + +/** + * The vendor, serving ONE shape. + * + * Everything the shape does not name answers the HEALTHY listing the live API answers with, so each + * test varies exactly one thing and the DELETE path is always open: a `revoke` that wanted to spend + * a DELETE here could always have spent one, which is what makes "no DELETE reached the vendor" a + * statement about the adapter rather than about the stub. + */ +function startEngine(shape: Shape): Engine { + const requests: string[] = []; + // Shape 5's truncation: 250 rows claimed, 100 served. The claim is the only thing that could say + // the listing is short, and it arrives as a STRING. + const truncated = shape === "total-count-is-a-string"; + const rows = Array.from({ length: truncated ? 250 : 3 }, (_, n) => + connectionRow(n), + ); + const served = truncated ? 100 : rows.length; + + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}${url.search}`); + const offset = Number(url.searchParams.get("offset") ?? "0"); + const limit = Number( + url.searchParams.get("limit") ?? String(CONNECTION_PAGE_SIZE), + ); + + // THE DELETE, always available and always counted. + if (url.pathname.startsWith("/v1/admin/user_connections/")) { + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/v1/admin/user_connections") { + if (shape === "listing-is-not-json") { + // 1. A 200 that is not JSON at all — a proxy's courtesy page, served with the status of + // a real answer. + return new Response("OK", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + if (shape === "listing-is-204") { + // 2. 204 with no body and `content-length: 0` on a PAGED endpoint. Nothing in it says + // how many connections exist; it says only that the vendor sent no list. + return new Response(null, { status: 204 }); + } + if (shape === "listing-is-json-null") { + // 3. Well-formed JSON whose whole value is `null`. + return new Response("null", { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (shape === "listing-item-is-null") { + // 4. A list with a hole in it. One row is unreadable, and the other two are real + // connections a DELETE is owed to. + return Response.json({ + items: [null, connectionRow(0), connectionRow(1)], + offset, + limit, + page_count: 3, + total_count: 3, + }); + } + const page = rows.slice(offset, Math.min(offset + limit, served)); + const body: Record = { + items: page, + offset, + limit, + page_count: page.length, + }; + // 5. The cross-check's only input, sent as a STRING. `reportedTotalOf` keeps numbers only, + // so "the vendor did not say" and "the vendor said 250" collapse into one answer. + body.total_count = truncated ? String(rows.length) : rows.length; + return Response.json(body); + } + + if (url.pathname === "/v1/admin/auth_providers") { + return Response.json({ + items: [ + { id: "google-openbot", provider_id: "google", type: "oauth2" }, + { id: "arcade-microsoft", provider_id: "microsoft", type: "oauth2" }, + ], + offset: 0, + limit, + page_count: 2, + total_count: 2, + }); + } + + if (url.pathname === "/v1/tools") { + const toolkit = url.searchParams.get("toolkit") ?? "Gmail"; + const providers = providersFor(shape); + const page = + offset === 0 + ? Array.from({ length: 4 }, (_, n) => toolRow(toolkit, n, providers)) + : []; + return Response.json({ + items: page, + offset, + limit, + page_count: page.length, + total_count: 4, + }); + } + + return new Response("not found", { status: 404 }); + }, + }); + return { port: server.port, requests, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(shape: Shape): Engine { + running = startEngine(shape); + // `new Arcade({ apiKey })` reads this at construction, so the server starts FIRST. + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +/** How many DELETEs actually reached the vendor. Half the invariant is counted here. */ +const deletesSpent = (e: Engine): number => + e.requests.filter( + (r) => r.startsWith("DELETE ") && r.includes("/v1/admin/user_connections/"), + ).length; + +type Outcome = + | { kind: "returned"; value: boolean } + | { kind: "threw"; message: string }; + +type Broker = ReturnType["broker"]; + +async function revokeOutcome(broker: Broker): Promise { + try { + return { + kind: "returned", + value: await broker.revoke({ account, toolkit: "Gmail" }), + }; + } catch (error) { + return { + kind: "threw", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +/** The seven shapes, with the sentence each one is enumerated under. */ +const SHAPES: [Shape, string][] = [ + ["listing-is-not-json", "a non-JSON 200 response to the connection listing"], + ["listing-is-204", "a 204 / content-length: 0 response on a paged endpoint"], + ["listing-is-json-null", "a body whose JSON is the literal null"], + ["listing-item-is-null", "items: [null] on the user-connections listing"], + [ + "total-count-is-a-string", + "total_count sent as a JSON string rather than a number", + ], + [ + "toolkit-names-no-provider", + "a toolkit whose actions name zero provider ids", + ], + [ + "toolkit-names-two-providers", + "a toolkit whose actions name two or more provider ids", + ], +]; + +describe("revoke never answers off a listing it could not read whole", () => { + for (const [shape, sentence] of SHAPES) { + test(sentence, async () => { + const e = engine(shape); + const { broker } = createArcadeClient("never-dialled"); + + const outcome = await revokeOutcome(broker); + const deletes = deletesSpent(e); + const answered = + outcome.kind === "threw" + ? "threw" + : `returned ${String(outcome.value)}`; + + // (1) `false` IS A CLAIM ABOUT THE VENDOR'S SIDE and may never be made for free. Asserted + // first and on its own, because this is the live defect's exact signature: `false` plus + // zero DELETEs, with the caller deleting the local row regardless. + const orphansAGrant = answered === "returned false" && deletes === 0; + expect({ shape, orphansAGrant }).toEqual({ shape, orphansAGrant: false }); + + // (2) AND THE LISTING WAS UNVERIFIABLE IN EVERY ONE OF THE SEVEN, so no answer is available + // at all. `broker.ts:712-717`: an unreadable or partial ask must THROW, so the local row + // survives to name the app on the second press of Disconnect. + expect({ shape, answered, deletesSpent: deletes }).toEqual({ + shape, + answered: "threw", + deletesSpent: deletes, + }); + }); + } +}); From 3dd458250057194ee25478986171f687394d73ea Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 02:28:49 +0200 Subject: [PATCH 158/232] test(arcade): refuse a stub vendor that never bound a port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Bun.serve().port` is typed `number | undefined`, and the engine harness assigned it straight into `Engine.port: number`. `server/tests` sits outside `tsc`, so `bun run typecheck` could not see it — the same TS2322 class that CL-4 is being added to catch, in the very file CL-5 adds. Refuse loudly instead of coercing: a stub that never bound a port would send every request in this file to a nonsense address, and the revoke invariant would then "hold" for the wrong reason. Behaviour unchanged: 3 pass / 4 fail before and after, same four shapes red. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-revoke-invariant.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/tests/arcade-revoke-invariant.test.ts b/server/tests/arcade-revoke-invariant.test.ts index b856443d6..e3738afdc 100644 --- a/server/tests/arcade-revoke-invariant.test.ts +++ b/server/tests/arcade-revoke-invariant.test.ts @@ -220,7 +220,12 @@ function startEngine(shape: Shape): Engine { return new Response("not found", { status: 404 }); }, }); - return { port: server.port, requests, stop: () => server.stop(true) }; + // `Bun.serve().port` is `number | undefined`. Refuse loudly rather than coercing: a stub that + // never bound a port would otherwise send every request of this file to a nonsense address and + // the invariant below would "hold" for the wrong reason. + const { port } = server; + if (port === undefined) throw new Error("the stub vendor did not bind a port"); + return { port, requests, stop: () => server.stop(true) }; } let running: Engine | null = null; From 1be64c6a791baa4cb35a89fe4508d9fefd1fd772 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 02:31:07 +0200 Subject: [PATCH 159/232] test(support): one shared source scanner, and the contract test for its enumerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five guards each re-implemented file enumeration, comment stripping and line splitting, and every copy had its blind spot in the ENUMERATOR rather than the matcher: a non-recursive readdirSync, a block comment blanked to "" (newlines and all) so every line after it is reported at the wrong number, one stripper that drops whole lines so a trailing comment takes its own code with it, and a per-physical-line match that sees neither half of a prettier-wrapped expression. None of this was reachable by the guards' own positive controls, which plant their offender in a file the enumerator already visits and so can only ever prove the pattern. server/tests/support/source-scan.ts holds the three primitives once; source-scan.test.ts is the contract test for them, over fixtures it writes to a temp dir — .js/.jsx/.mjs, a nested subdirectory, a duplicate basename, a wrapped expression, a trailing comment, a block comment's line numbering and a same-line offence after whitespace. The six call sites are moved onto it with no change to what any of them asserts: same patterns, same exemptions, same comment skip in brokered-provenance, and the ts-expect-error scan deliberately keeps physical lines and unstripped comments, since the directive IS a comment and binds to the line below it. No production source is touched. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin-plugins-vocabulary-wiring.test.ts | 37 ++-- app/tests/arcade-no-browser-calls.test.ts | 33 ++-- app/tests/search-text.test.ts | 34 ++-- server/tests/brokered-provenance.test.ts | 27 +-- server/tests/store-scheme-sites.test.ts | 16 +- server/tests/support/source-scan.test.ts | 145 +++++++++++++++ server/tests/support/source-scan.ts | 168 ++++++++++++++++++ server/tests/ts-expect-error-coverage.test.ts | 45 ++--- 8 files changed, 394 insertions(+), 111 deletions(-) create mode 100644 server/tests/support/source-scan.test.ts create mode 100644 server/tests/support/source-scan.ts diff --git a/app/tests/admin-plugins-vocabulary-wiring.test.ts b/app/tests/admin-plugins-vocabulary-wiring.test.ts index 12f7b590d..f242a52ae 100644 --- a/app/tests/admin-plugins-vocabulary-wiring.test.ts +++ b/app/tests/admin-plugins-vocabulary-wiring.test.ts @@ -45,25 +45,28 @@ * passing vacuously. */ import { expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { + logicalLines, + sourceFiles, + stripComments, +} from "../../server/tests/support/source-scan"; const DIR = join(import.meta.dir, "../src/routes/_authed/admin/plugins"); -function stripComments(src: string): string { - return src - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(^|[^:"'`\w])\/\/.*$/gm, "$1"); -} - function load(name: string): string { return stripComments(readFileSync(join(DIR, name), "utf8")); } -/** Every route file in the directory, so a new one is scanned the day it lands. */ -const ALL_ROUTES = readdirSync(DIR) - .filter((name) => name.endsWith(".tsx")) - .sort(); +/** + * Every route file under the directory, so a new one is scanned the day it lands — and a new one + * in a SUBdirectory too, which a `readdirSync` of the top level would have walked straight past. + * Names are relative to `DIR`, so a top-level route still reads as `arcade.tsx`. + */ +const ALL_ROUTES = sourceFiles(DIR, [".tsx"]).map((p) => + p.slice(DIR.length + 1), +); const VOCAB_CALLERS: Record = { "index.tsx": [ @@ -96,7 +99,7 @@ test("the plugins routes carry no Composio literal", () => { for (const name of NO_VENDOR_LITERAL) { const text = load(name); expect(text.length).toBeGreaterThan(500); - text.split("\n").forEach((line, i) => { + for (const { line: at, text: line } of logicalLines(text)) { // Capitalised AND all-caps. `COMPOSIO_API_KEY` is a user-visible string 1.8 moved // into the vocabulary (`envVar`), rendered as "Set COMPOSIO_API_KEY on this // deployment." — it must read ARCADE_API_KEY on an Arcade deployment, and an @@ -104,8 +107,8 @@ test("the plugins routes carry no Composio literal", () => { // case-insensitive: `composioConfigured` and the `/_authed/admin/plugins/composio` // route id are legitimate and must stay legal. if (/Composio|COMPOSIO/.test(line)) - offenders.push(`${name}:${i + 1}: ${line.trim()}`); - }); + offenders.push(`${name}:${at}: ${line.trim()}`); + } } expect(offenders).toEqual([]); }); @@ -118,11 +121,11 @@ test("no plugins route hard-codes a directory path", () => { for (const name of ALL_ROUTES) { const text = load(name); expect(text.length).toBeGreaterThan(500); - text.split("\n").forEach((line, i) => { + for (const { line: at, text: line } of logicalLines(text)) { // The route's own file id carries `_authed` and is not somewhere anybody navigates. if (/(? { - const files = walk(SRC_DIR); + // Enumeration and line-splitting come from the shared scanner, so the walk is recursive (a + // future `src//` is scanned the day it lands) and a dial prettier wrapped across two + // physical lines is one line to the patterns. Comments are deliberately NOT stripped: a + // docblock naming `arcade.dev` is still this guard's business. + const files = sourceFiles(SRC_DIR, [".ts", ".tsx"]); expect(files.length).toBeGreaterThan(50); const offenders: string[] = []; for (const file of files) { - const lines = readFileSync(file, "utf8").split("\n"); - lines.forEach((line, i) => { - const text = line.replace(ROW_URL, ""); - if (PATTERNS.some((re) => re.test(text))) { - offenders.push(`${file.slice(SRC_DIR.length + 1)}:${i + 1}`); + for (const { line, text } of logicalLines(readFileSync(file, "utf8"))) { + const scanned = text.replace(ROW_URL, ""); + if (PATTERNS.some((re) => re.test(scanned))) { + offenders.push(`${file.slice(SRC_DIR.length + 1)}:${line}`); } - }); + } } expect(offenders).toEqual([]); }); diff --git a/app/tests/search-text.test.ts b/app/tests/search-text.test.ts index 4cc3f4ff9..4a074078f 100644 --- a/app/tests/search-text.test.ts +++ b/app/tests/search-text.test.ts @@ -13,13 +13,17 @@ * long to survive a double. The guard at the bottom keeps the `typeof` form from coming back. */ import { expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { parseSearchText, searchText, searchTextEntry, } from "@/lib/search-text"; +import { + logicalLines, + sourceFiles, +} from "../../server/tests/support/source-scan"; test("text is recovered from whatever the router parsed the value into", () => { expect(searchText("abc")).toBe("abc"); @@ -73,28 +77,20 @@ test("a parameter that is not there stays not there", () => { const ROUTES_DIR = join(import.meta.dir, "../src/routes"); const DROPPED_STRING = /typeof\s+search\??\.\w+\s*===\s*["']string["']/; -function walk(dir: string): string[] { - const out: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) out.push(...walk(full)); - else if (/\.tsx?$/.test(entry.name)) out.push(full); - } - return out.sort(); -} - test("no route reads a search parameter by guarding on its parsed type", () => { - const files = walk(ROUTES_DIR); + // The walk and the line-splitting are the shared scanner's (server/tests/support): recursive, + // and LOGICAL lines — prettier wraps `typeof search.flowId ===` away from its `"string"`, and + // read one physical line at a time this guard would have reported green on the very shape it + // exists to catch. + const files = sourceFiles(ROUTES_DIR, [".ts", ".tsx"]); expect(files.length).toBeGreaterThan(10); const offenders: string[] = []; for (const file of files) { - readFileSync(file, "utf8") - .split("\n") - .forEach((line, index) => { - if (DROPPED_STRING.test(line)) { - offenders.push(`${file.slice(ROUTES_DIR.length + 1)}:${index + 1}`); - } - }); + for (const { line, text } of logicalLines(readFileSync(file, "utf8"))) { + if (DROPPED_STRING.test(text)) { + offenders.push(`${file.slice(ROUTES_DIR.length + 1)}:${line}`); + } + } } expect(offenders).toEqual([]); }); diff --git a/server/tests/brokered-provenance.test.ts b/server/tests/brokered-provenance.test.ts index d65685ea9..3cf8f0533 100644 --- a/server/tests/brokered-provenance.test.ts +++ b/server/tests/brokered-provenance.test.ts @@ -41,8 +41,9 @@ * still matches are the definitions themselves, in the three exempt modules it does not scan. */ import { expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { logicalLines, sourceFiles } from "./support/source-scan"; const PLUGINS_DIR = join(import.meta.dir, "../src/plugins"); @@ -77,17 +78,21 @@ function isComment(line: string): boolean { test("no plugin module hard-codes a brokered vendor name outside the vocabulary modules", () => { const offenders: string[] = []; - const files = readdirSync(PLUGINS_DIR) - .filter((f) => f.endsWith(".ts") && !VOCABULARY.has(f)) - .sort(); + // The shared scanner's walk and line-splitting. RECURSIVE: a `src/plugins//x.ts` was + // unscanned before, and the exemption is by FILE NAME, so a `plugins//access.ts` is not + // quietly exempt either. LOGICAL lines: a scheme prettier wrapped off its own `=` is one + // decision, and reading it per physical line saw half of it. The comment skip is unchanged and + // still narrow — only a line that STARTS as prose is spared. + const files = sourceFiles(PLUGINS_DIR, [".ts"]).filter( + (f) => !VOCABULARY.has(basename(f)), + ); for (const file of files) { - const lines = readFileSync(join(PLUGINS_DIR, file), "utf8").split("\n"); - lines.forEach((line, i) => { - if (isComment(line)) return; - if (PATTERNS.some((re) => re.test(line))) { - offenders.push(`${file}:${i + 1}`); + for (const { line, text } of logicalLines(readFileSync(file, "utf8"))) { + if (isComment(text)) continue; + if (PATTERNS.some((re) => re.test(text))) { + offenders.push(`${file.slice(PLUGINS_DIR.length + 1)}:${line}`); } - }); + } } expect(offenders).toEqual([]); }); diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts index 1f04aacb0..a19b51793 100644 --- a/server/tests/store-scheme-sites.test.ts +++ b/server/tests/store-scheme-sites.test.ts @@ -27,6 +27,7 @@ import { createPluginStore, } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { stripComments } from "./support/source-scan"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); @@ -259,17 +260,10 @@ test("shared-use-store derives its scheme from BROKERED_PROFILE and nowhere else join(import.meta.dir, "../src/plugins/shared-use-store.ts"), "utf8", ); - const code = source - .split("\n") - .filter((line) => { - const start = line.trimStart(); - return !( - start.startsWith("*") || - start.startsWith("//") || - start.startsWith("/*") - ); - }) - .join("\n"); + // The shared stripper, which blanks a comment where it stands instead of dropping the line it + // sits on: a trailing `// note` used to take its own code with it, so the assertion below could + // have failed on a line that carries the derivation it is looking for. + const code = stripComments(source); expect(code).toContain("BROKERED_PROFILE[provider].scheme"); // The local derivation 4.5 could not avoid, now that the record is reachable. expect(code).not.toMatch(/\$\{provider\}:\/\//); diff --git a/server/tests/support/source-scan.test.ts b/server/tests/support/source-scan.test.ts new file mode 100644 index 000000000..db402af9b --- /dev/null +++ b/server/tests/support/source-scan.test.ts @@ -0,0 +1,145 @@ +// server/tests/support/source-scan.test.ts +/** + * Contract test for the shared scanner, written against the ENUMERATOR rather than the matcher. + * + * Every guard that grew its own copy of these primitives also grew a positive control, and every + * one of those controls plants its offender in a file the enumerator already visits. A control + * sited that way can only ever prove the pattern matches; it is structurally incapable of noticing + * that the walk never reached the file, that the extension filter excluded it, that the line number + * it reported was off by the height of a block comment, or that the offending expression was + * wrapped across two physical lines. So the cases below are the ones no guard's own control can + * reach: they build a fixture tree on disk, with nesting, extensions and formatting the real + * sources may grow at any time, and ask the util what it sees. + */ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { logicalLines, sourceFiles, stripComments } from "./source-scan"; + +let root = ""; + +/** Write `text` at `rel` under the fixture root, creating the directories it needs. */ +function fixture(rel: string, text: string): string { + const full = join(root, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, text, "utf8"); + return full; +} + +/** Paths relative to the fixture root, so an assertion reads as the tree it describes. */ +function scan(exts: string[]): string[] { + return sourceFiles(root, exts).map((p) => p.slice(root.length + 1)); +} + +const ALL = [".ts", ".tsx", ".js", ".jsx", ".mjs"]; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "source-scan-")); + fixture("top.ts", "export const a = 1;\n"); + fixture("plain.js", "export const b = 2;\n"); + fixture("widget.jsx", "export const C = () => null;\n"); + fixture("module.mjs", "export const d = 4;\n"); + fixture("notes.md", "not source\n"); + fixture("nested/deep/buried.ts", "export const e = 5;\n"); + fixture("nested/index.ts", "export const f = 6;\n"); + fixture("other/index.ts", "export const g = 7;\n"); +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +/* 1-4: WHAT THE WALK REACHES. Each of these is a file a non-recursive, .ts-only enumerator drops + * on the floor while its guard reports green. */ + +test("a .js file is enumerated", () => { + expect(scan(ALL)).toContain("plain.js"); + // And the extension list is honoured, so a guard that asks for .ts only still gets .ts only. + expect(scan([".ts"])).not.toContain("plain.js"); +}); + +test("a .jsx file is enumerated", () => { + expect(scan(ALL)).toContain("widget.jsx"); + expect(scan([".tsx"])).not.toContain("widget.jsx"); +}); + +test("an .mjs file is enumerated", () => { + expect(scan(ALL)).toContain("module.mjs"); + expect(scan(ALL)).not.toContain("notes.md"); +}); + +test("a file in a nested subdirectory is enumerated", () => { + expect(scan(ALL)).toContain(join("nested", "deep", "buried.ts")); + // The whole point: the directory the old enumerators stopped at holds more than it shows. + expect(scan(ALL).length).toBeGreaterThan(5); +}); + +/* 5: WHAT A LINE IS. */ + +test("a prettier-wrapped expression is one logical line", () => { + const src = [ + "const keep =", + " typeof search.x ===", + ' "string";', + "const other = 1;", + ].join("\n"); + const lines = logicalLines(src); + const wrapped = lines[0]; + expect(wrapped.line).toBe(1); + expect(wrapped.text).toMatch(/typeof\s+search\.x\s*===\s*"string"/); + // And it did not swallow the statement after it. + expect(lines[1]).toEqual({ line: 4, text: "const other = 1;" }); +}); + +/* 6-8: WHAT THE STRIPPER LEAVES BEHIND. */ + +test("a trailing comment does not remove its own code line", () => { + const src = 'const url = "composio://gmail"; // the scheme\n'; + const stripped = stripComments(src); + expect(stripped).toContain('const url = "composio://gmail";'); + expect(stripped).not.toContain("the scheme"); + // A stripper that drops the whole line would have failed its guard on a line holding no offence. + expect(logicalLines(stripped)[0].text.trim()).toBe( + 'const url = "composio://gmail";', + ); +}); + +test("a block comment spanning lines preserves subsequent line numbers", () => { + const src = [ + "/*", + " * three lines of prose", + " */", + 'const url = "arcade://gmail";', + ].join("\n"); + const stripped = stripComments(src); + expect(stripped.split("\n").length).toBe(4); + const offender = logicalLines(stripped).find((l) => + /arcade:\/\//.test(l.text), + ); + // 4, as the file reads it — not 1, which is what blanking a block comment to "" reports. + expect(offender?.line).toBe(4); +}); + +test("a same-line offence after whitespace is still visible", () => { + const src = ' /* why */ const url = "arcade://gmail"; /* trailing */\n'; + const stripped = stripComments(src); + expect(stripped).toContain('const url = "arcade://gmail";'); + expect(stripped).not.toContain("why"); + expect(stripped).not.toContain("trailing"); + // Columns are preserved too, so the code sits where it sat. + expect(stripped.split("\n")[0].length).toBe(src.length - 1); + expect(logicalLines(stripped)[0].line).toBe(1); +}); + +/* 9: WHAT A FILE IS CALLED. */ + +test("a duplicate-name decoy does not collide", () => { + const found = scan(ALL).filter((p) => p.endsWith("index.ts")); + expect(found.sort()).toEqual([ + join("nested", "index.ts"), + join("other", "index.ts"), + ]); + // Two distinct files with one basename: an enumerator keyed on the name would report one. + expect(new Set(found).size).toBe(2); +}); diff --git a/server/tests/support/source-scan.ts b/server/tests/support/source-scan.ts new file mode 100644 index 000000000..ad776a671 --- /dev/null +++ b/server/tests/support/source-scan.ts @@ -0,0 +1,168 @@ +// server/tests/support/source-scan.ts +/** + * The four primitives every text-scanning guard needs, in one place: enumerate the files, strip the + * comments, split into lines a pattern can be matched against. + * + * WHY THIS EXISTS. Five guards each grew their own copy of these, and every copy had the same shape + * of blind spot — not in the pattern it matches, but in the text it ever gets to see: + * + * - a NON-RECURSIVE `readdirSync`, so a rule stated over a directory silently stops at the first + * subdirectory somebody adds. {@link sourceFiles} walks. + * - a comment stripper that replaces a block comment with `""`, NEWLINES AND ALL, so every line + * after it is reported at the wrong number — or one that drops whole lines, which turns a + * trailing `// note` into the removal of the code it trails and can fail a guard on a line that + * holds no offence. {@link stripComments} blanks a comment in place: same bytes out as in, minus + * the comment, with every newline where it was. + * - a per-PHYSICAL-line match, so a prettier-wrapped `typeof search.x ===\n"string"` is two lines + * to the guard and one expression to the compiler, and the guard sees neither half. + * {@link logicalLines} joins a wrapped expression back up and reports the line it STARTED on. + * + * Each guard's positive control plants its offender in a file the enumerator already visits, which + * is exactly why none of these were caught by the controls: a control sited that way proves the + * matcher and says nothing about the enumerator. `source-scan.test.ts` is the contract test for the + * enumerator half, over fixtures it writes itself. + */ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Every file under `root` whose name ends in one of `exts`, RECURSIVELY, sorted. + * + * Sorted so an offender list is stable, and recursive so `src/plugins//x.ts` is scanned the day + * it lands rather than the day somebody notices. Symlinks are not followed (a `node_modules` link is + * not source). + */ +export function sourceFiles(root: string, exts: string[]): string[] { + const out: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const full = join(root, entry.name); + if (entry.isDirectory()) out.push(...sourceFiles(full, exts)); + else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(full); + } + return out.sort(); +} + +/** + * Line and block comments removed, EVERY OTHER BYTE POSITION LEFT ALONE. + * + * A comment is overwritten with spaces and its newlines are kept, so line numbers — and columns — + * are those of the real file. String literals are copied through untouched, so a `"// not a + * comment"` or an `"https://host"` inside a string survives; a single- or double-quoted string is + * taken to end at a newline, as it does in the language. Regex literals are not parsed, which is the + * known limit: a regex holding `//` or `/*` can still be read as a comment. + */ +export function stripComments(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (c === '"' || c === "'" || c === "`") { + let j = i + 1; + while (j < text.length) { + if (text[j] === "\\") { + j += 2; + continue; + } + if (text[j] === c) { + j++; + break; + } + if (c !== "`" && text[j] === "\n") break; + j++; + } + out += text.slice(i, j); + i = j; + } else if (c === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") { + out += " "; + i++; + } + } else if (c === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + const stop = end === -1 ? text.length : end + 2; + for (; i < stop; i++) out += text[i] === "\n" ? "\n" : " "; + } else { + out += c; + i++; + } + } + return out; +} + +/** A logical line, and the 1-based PHYSICAL line it started on. */ +export type LogicalLine = { line: number; text: string }; + +/** Brackets opened and not closed on this line, strings skipped. */ +function bracketDelta(line: string): number { + let delta = 0; + let i = 0; + while (i < line.length) { + const c = line[i]; + if (c === '"' || c === "'" || c === "`") { + i++; + while (i < line.length && line[i] !== c) i += line[i] === "\\" ? 2 : 1; + i++; + continue; + } + if (c === "(" || c === "[") delta++; + else if (c === ")" || c === "]") delta--; + i++; + } + return delta; +} + +/** Ends on something that cannot end a statement, so the expression continues on the next line. */ +const DANGLING = /(?:=>|[-+*%<>=!&|?:.,^~])$/; + +/** + * The most physical lines one logical line may absorb. + * + * A wrapped EXPRESSION is short — prettier breaks at 80 columns, so the two or three lines of a + * `typeof x ===` / `"string"` or an `eq(` / arg / arg / `)` are the whole population this exists + * for. A bracket, by contrast, can stay open for the length of a route handler, and joining a + * hundred-line `routes.get("/", …)` into one line would report every offence inside it at the + * handler's first line and splice its docblocks onto its code. So the join stops here and the + * remaining lines start a new logical line: a long call is read as the many lines it is written on, + * which is what the old per-physical-line scan did anyway. + */ +const MAX_JOIN = 5; + +/** + * Physical lines joined back into the expressions they were wrapped out of. + * + * Prettier breaks a long expression at an operator or inside a bracket, so `typeof search.x ===` + * and `"string"` are one decision written on two lines. A guard matching per physical line sees + * neither half and reports green. Lines are joined with a single space while a bracket is open or + * the line ends dangling, for at most {@link MAX_JOIN} physical lines; `{` and `}` are NOT counted, + * so a block body stays many logical lines rather than collapsing into one. Comments do not get a + * vote on where a logical line ends — their brackets and their trailing operators are discounted, + * though their text is kept in the joined line, since whether prose is an offence is the caller's + * rule and not this function's. The reported `line` is where the logical line STARTED, which is the + * line a reader is sent to. + */ +export function logicalLines(text: string): LogicalLine[] { + const out: LogicalLine[] = []; + const physical = text.split("\n"); + let start = 0; + let buffer = ""; + let depth = 0; + let open = false; + for (let i = 0; i < physical.length; i++) { + const raw = physical[i]; + if (!open) { + start = i; + buffer = raw; + depth = 0; + } else { + buffer += ` ${raw.trim()}`; + } + const code = stripComments(raw); + depth += bracketDelta(code); + open = depth > 0 || DANGLING.test(code.trimEnd()); + if (open && i - start + 1 >= MAX_JOIN) open = false; + if (open && i < physical.length - 1) continue; + out.push({ line: start + 1, text: buffer }); + open = false; + } + return out; +} diff --git a/server/tests/ts-expect-error-coverage.test.ts b/server/tests/ts-expect-error-coverage.test.ts index f56f38b45..453313d62 100644 --- a/server/tests/ts-expect-error-coverage.test.ts +++ b/server/tests/ts-expect-error-coverage.test.ts @@ -19,8 +19,9 @@ * respects strings before it is parsed. */ import { expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; +import { sourceFiles, stripComments } from "./support/source-scan"; const SERVER_DIR = resolve(import.meta.dir, ".."); const TESTS_DIR = join(SERVER_DIR, "tests"); @@ -28,33 +29,12 @@ const TESTS_DIR = join(SERVER_DIR, "tests"); /** The directive, in either comment form. A bare mention in prose does not match. */ const DIRECTIVE = /(\/\/|\/\*)\s*@ts-expect-error\b/; -/** JSONC to JSON: drop `//` and block comments, leaving string contents (and their slashes) alone. */ -function stripJsonComments(text: string): string { - let out = ""; - let i = 0; - while (i < text.length) { - const c = text[i]; - if (c === '"') { - let j = i + 1; - while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1; - out += text.slice(i, j + 1); - i = j + 1; - } else if (c === "/" && text[i + 1] === "/") { - while (i < text.length && text[i] !== "\n") i++; - } else if (c === "/" && text[i + 1] === "*") { - const end = text.indexOf("*/", i + 2); - i = end === -1 ? text.length : end + 2; - } else { - out += c; - i++; - } - } - return out; -} - function readInclude(): string[] { const raw = readFileSync(join(SERVER_DIR, "tsconfig.json"), "utf8"); - const include = JSON.parse(stripJsonComments(raw)).include; + // The shared stripper does the JSONC: it leaves string contents (and their slashes) alone, and + // blanks a comment in place rather than deleting it, which JSON.parse does not care about and a + // reader comparing the text to the file does. + const include = JSON.parse(stripComments(raw)).include; if (!Array.isArray(include)) throw new Error("tsconfig.json has no include"); return include.map((p: string) => resolve(SERVER_DIR, p)); } @@ -64,18 +44,13 @@ function isCovered(file: string, include: string[]): boolean { return include.some((p) => file === p || file.startsWith(`${p}/`)); } -function walk(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { - const p = join(dir, e.name); - if (e.isDirectory()) return walk(p); - return e.name.endsWith(".ts") || e.name.endsWith(".tsx") ? [p] : []; - }); -} - test("every test file carrying a ts-expect-error directive is named in tsconfig include", () => { const include = readInclude(); const offenders: string[] = []; - for (const file of walk(TESTS_DIR).sort()) { + // The shared scanner's walk. Its comment stripper is used above for the JSONC and deliberately + // NOT here: the directive IS a comment, and it binds to the physical line below it, so this is + // the one scan that stays on physical lines rather than logical ones. + for (const file of sourceFiles(TESTS_DIR, [".ts", ".tsx"])) { if (isCovered(file, include)) continue; readFileSync(file, "utf8") .split("\n") From 73b3954c2f2b8ffff008a7efcaa0e721f0bbd4ea Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 02:37:00 +0200 Subject: [PATCH 160/232] test(ci): type-check server/tests against a frozen baseline of its existing errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bunx tsc --noEmit -p server/tsconfig.json` reports CLEAN, and it is lying by omission: that config's `include` does not name `tests`, so the 382 files of the server suite are the one part of this repository no type-checker has ever read. The runner is bun, which erases types rather than checking them, so nothing else reads them either. 277 real type errors across 106 files were sitting there unseen. Two of them are `import type` specifiers pointing at a path that does not exist — including one in `tests/support/constraints.ts`, a SHARED helper, where the unresolved import degrades the value to `any` and voids the helper's type guarantee for every file that uses it. That is precisely the failure mode where a test keeps passing while asserting nothing. Adds `server/tsconfig.tests.json`, which EXTENDS `server/tsconfig.json` and adds `tests` to the inherited `include` (`extends` replaces `include` rather than merging it, hence the repetition). Checking these files with an ad-hoc `tsc file.ts` plus hand-written flags instead would be unsound: it drags `src` in under options the project does not use and emits a flood of diagnostics that are artefacts of the flags, not defects. The 277 existing errors are FROZEN in `server/tests/typecheck-baseline.txt` and `scripts/typecheck-tests.ts` fails only on an error that is not in it. Fixing them is a sweep, not this change; gating on them today would mean landing a red build and waiting. Baselining lets the check start earning its keep immediately — an error introduced from now on fails CI — while the known debt is paid down on its own schedule. Removing a line is always allowed; the script reports an entry that no longer occurs and still exits 0, because fixing an error must never break the build. Adding a line takes `--update` and a deliberate decision. Comparison is a multiset, so a fourth copy of an error baselined three times is caught rather than swallowed, and output that parses to no diagnostics but is not empty fails rather than passing — a gate that goes green when its checker did not run is the defect class this exists to kill. Strictly additive: no file under `server/src` or `app/src` changes, and nothing in the existing `bun run typecheck` moves. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 10 + package.json | 1 + scripts/typecheck-tests.ts | 150 ++++++++++++++ server/tests/typecheck-baseline.txt | 290 ++++++++++++++++++++++++++++ server/tsconfig.tests.json | 19 ++ 5 files changed, 470 insertions(+) create mode 100644 scripts/typecheck-tests.ts create mode 100644 server/tests/typecheck-baseline.txt create mode 100644 server/tsconfig.tests.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d12cee722..8b9aefd74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,16 @@ jobs: - run: bun run format:check - run: bun run lint - run: bun run typecheck + # `typecheck` is `bun run --filter '*' typecheck`, and each workspace's `tsc --noEmit` reads + # its own tsconfig, none of which includes a test tree. The suite is therefore the one part + # of this repository that no type-checker has ever read: bun erases types rather than + # checking them, so a test could pass an options object where a connection string belongs, + # or import a type from a path that does not exist and silently get `any`, and every check + # here stayed green. This reads `server/tests` under the server's own compiler options and + # fails on any error that is not in `server/tests/typecheck-baseline.txt` — the errors that + # were already there when the check was turned on. New ones are new defects. + - name: server/tests types (baselined) + run: bun run typecheck:tests - name: Desktop version tracks the release version run: bun desktop/scripts/desktop-version.ts check # The chart's default image tag is its appVersion, and a release must move both together. They diff --git a/package.json b/package.json index 1abff0606..442e3a3d3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "bunx biome lint --error-on-warnings .", "test": "bun test", "typecheck": "bun run --filter '*' typecheck", + "typecheck:tests": "bun scripts/typecheck-tests.ts", "test:ci": "bun scripts/test-ci.ts", "test:server:sharded": "bun scripts/test-server-sharded.ts", "pretest": "bun run generate:app-config", diff --git a/scripts/typecheck-tests.ts b/scripts/typecheck-tests.ts new file mode 100644 index 000000000..4649ec853 --- /dev/null +++ b/scripts/typecheck-tests.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env bun +// Type-check `server/tests` against a frozen baseline of the errors that were already there. +// +// WHY A BASELINE AND NOT A CLEAN GATE. `server/tsconfig.json` does not include `tests`, and the +// runner is bun, which erases types rather than checking them, so `bun run typecheck` reports CLEAN +// while real defects sit in the suite — broken `import type` paths that silently degrade a value to +// `any` (including in a shared helper, which voids its type guarantee for every file that uses it), +// stubs missing methods their interface requires, `number | undefined` passed where `number` is +// required. Turning the check on is one change; fixing what it finds is another, much larger one. +// Freezing the known set lets the check land green and start earning its keep immediately: a NEW +// error fails the build from today, and the existing ones get fixed on their own schedule. +// +// WHY THE PROJECT'S OWN CONFIG. The check runs `tsc -p server/tsconfig.tests.json`, which extends +// `server/tsconfig.json`. Checking these files with an ad-hoc `tsc file.ts` plus hand-written flags +// is unsound: it drags `src` in under options the project does not use and emits a flood of errors +// that are artefacts of the flags rather than defects. Any baseline produced that way is garbage. +// +// Usage: +// bun scripts/typecheck-tests.ts # check against the baseline; exit 1 on a new error +// bun scripts/typecheck-tests.ts --update # rewrite the baseline from the current output + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const project = join("server", "tsconfig.tests.json"); +const baselinePath = join( + repoRoot, + "server", + "tests", + "typecheck-baseline.txt", +); + +const header = [ + "# Frozen type errors in `server/tests`, as reported by", + "# bunx tsc --noEmit -p server/tsconfig.tests.json", + "# One line per error: :: error TS: . Column and the indented", + "# elaboration lines are dropped so the baseline does not churn on reformatting.", + "#", + "# This file is a RECORD OF DEBT, not a list of things that are fine. Every line is a real defect", + "# that the suite could not see because it was never type-checked. Fixing one and deleting its", + "# line is always correct. ADDING a line is not: a new error means code written today is wrong,", + "# and the gate exists to say so. Regenerate with `bun scripts/typecheck-tests.ts --update` only", + "# after a deliberate decision that the new entries are pre-existing debt being surfaced rather", + "# than debt being created.", + "#", + "# Sorted. Duplicates are significant: a repeated line means that many identical errors.", +]; + +// `path(line,col): error TScode: message` — the first line of a diagnostic. Elaborations are +// indented, so anchoring on a non-space first character drops them. +const DIAGNOSTIC = /^(\S[^(]*)\((\d+),\d+\): error (TS\d+): (.*)$/; + +function runTsc(): string { + const result = Bun.spawnSync({ + cmd: ["bunx", "tsc", "--noEmit", "--pretty", "false", "-p", project], + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + }); + return `${result.stdout.toString()}${result.stderr.toString()}`; +} + +function parse(output: string): string[] { + const errors: string[] = []; + for (const raw of output.split("\n")) { + const match = DIAGNOSTIC.exec(raw.trimEnd()); + if (!match) continue; + const [, file, line, code, message] = match; + // tsc prints the project-relative path in the location, but absolute paths inside some + // messages (TS6142 names the resolved file). Strip the checkout prefix so the baseline is + // the same on a laptop, in a worktree and on CI. + const normalized = `${file}:${line}: error ${code}: ${message}`.replaceAll( + `${repoRoot}/`, + "", + ); + errors.push(normalized); + } + return errors.sort(); +} + +function readBaseline(): string[] { + if (!existsSync(baselinePath)) return []; + return readFileSync(baselinePath, "utf8") + .split("\n") + .filter((line) => line.trim() !== "" && !line.startsWith("#")) + .sort(); +} + +// Multiset difference: three identical errors in the baseline and four in the output is ONE new +// error, not zero. A set would swallow it. +function subtract(from: string[], remove: string[]): string[] { + const counts = new Map(); + for (const line of remove) counts.set(line, (counts.get(line) ?? 0) + 1); + const left: string[] = []; + for (const line of from) { + const n = counts.get(line) ?? 0; + if (n > 0) counts.set(line, n - 1); + else left.push(line); + } + return left; +} + +const output = runTsc(); +const actual = parse(output); + +// A tsc crash, a bad config or a missing dependency produces output with no parseable diagnostic +// and a non-empty body. Treat that as a failure rather than as "zero errors, all good" — a gate +// that goes green when its checker did not run is the defect this whole exercise exists to kill. +if (actual.length === 0 && output.trim() !== "") { + console.error("tsc produced output but no parseable diagnostics:\n"); + console.error(output); + process.exit(1); +} + +if (process.argv.includes("--update")) { + writeFileSync(baselinePath, `${[...header, ...actual].join("\n")}\n`); + console.log(`Wrote ${actual.length} baseline entries to ${baselinePath}`); + process.exit(0); +} + +const baseline = readBaseline(); +const added = subtract(actual, baseline); +const fixed = subtract(baseline, actual); + +if (fixed.length > 0) { + // Not a failure. Fixing an error must never break the build; it only makes the baseline stale. + console.log( + `${fixed.length} baselined error(s) no longer occur. Drop them from the baseline:`, + ); + for (const line of fixed) console.log(` - ${line}`); + console.log(""); +} + +if (added.length > 0) { + console.error( + `${added.length} NEW type error(s) in server/tests, not in the baseline:\n`, + ); + for (const line of added) console.error(` ${line}`); + console.error( + "\nFix them. Do not run --update to silence them: the baseline records debt that predates" + + "\nthe gate, and an error reported here was introduced after it.", + ); + process.exit(1); +} + +console.log( + `server/tests type-check clean against the baseline (${actual.length} known error(s)).`, +); diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt new file mode 100644 index 000000000..c683c23f8 --- /dev/null +++ b/server/tests/typecheck-baseline.txt @@ -0,0 +1,290 @@ +# Frozen type errors in `server/tests`, as reported by +# bunx tsc --noEmit -p server/tsconfig.tests.json +# One line per error: :: error TS: . Column and the indented +# elaboration lines are dropped so the baseline does not churn on reformatting. +# +# This file is a RECORD OF DEBT, not a list of things that are fine. Every line is a real defect +# that the suite could not see because it was never type-checked. Fixing one and deleting its +# line is always correct. ADDING a line is not: a new error means code written today is wrong, +# and the gate exists to say so. Regenerate with `bun scripts/typecheck-tests.ts --update` only +# after a deliberate decision that the new entries are pre-existing debt being surfaced rather +# than debt being created. +# +# Sorted. Duplicates are significant: a repeated line means that many identical errors. +server/tests/account-mode.integration.test.ts:373: error TS2769: No overload matches this call. +server/tests/admin-people-credentials-validation.test.ts:53: error TS2556: A spread argument must either have a tuple type or be passed to a rest parameter. +server/tests/agent-auth-header.test.ts:21: error TS2741: Property 'updateSecret' is missing in type '{ create: () => Promise<{ id: string; revokedAt: null; }>; rotate: (input: CredentialStoreValue & { previousCredentialId: string; }) => Promise<{ id: string; revokedAt: null; }>; revoke: () => Promise<...>; isLive: (id: string) => Promise<...>; findLiveByKey: () => Promise<...>; }' but required in type 'CredentialStore'. +server/tests/agent-endpoint.test.ts:129: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. +server/tests/agent-endpoint.test.ts:142: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. +server/tests/agent-endpoint.test.ts:154: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. +server/tests/agent-endpoint.test.ts:166: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. +server/tests/agent-endpoint.test.ts:179: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. +server/tests/agent-endpoint.test.ts:192: error TS2741: Property 'preconnect' is missing in type '(_url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. +server/tests/agent-endpoint.test.ts:200: error TS2769: No overload matches this call. +server/tests/agent-endpoint.test.ts:223: error TS7006: Parameter 'value' implicitly has an 'any' type. +server/tests/agent-endpoint.test.ts:235: error TS2769: No overload matches this call. +server/tests/agent-endpoint.test.ts:245: error TS7006: Parameter 'value' implicitly has an 'any' type. +server/tests/agent-handoff-endtoend.integration.test.ts:117: error TS2345: Argument of type '{ desk: HandoffDesk; from: { botId: string; actorId: string; runId: string; threadId: string; depth: number; }; hasSomebodyToAsk: true; maxDepth: number; }' is not assignable to parameter of type '{ desk: HandoffDesk; from: RunAssertion; hasSomebodyToAsk: boolean; maxDepth: number; maxPerRun: number; approvalGate?: ApprovalGate | undefined; privateShare?: PrivateShareGate | undefined; }'. +server/tests/agent-handoff-endtoend.integration.test.ts:173: error TS2345: Argument of type '{ desk: HandoffDesk; from: { botId: string; actorId: string; runId: string; threadId: string; depth: number; }; hasSomebodyToAsk: true; maxDepth: number; }' is not assignable to parameter of type '{ desk: HandoffDesk; from: RunAssertion; hasSomebodyToAsk: boolean; maxDepth: number; maxPerRun: number; approvalGate?: ApprovalGate | undefined; privateShare?: PrivateShareGate | undefined; }'. +server/tests/agent-handoff-endtoend.integration.test.ts:217: error TS2345: Argument of type '{ desk: HandoffDesk; from: { botId: string; actorId: string; runId: string; threadId: string; depth: number; }; hasSomebodyToAsk: true; maxDepth: number; }' is not assignable to parameter of type '{ desk: HandoffDesk; from: RunAssertion; hasSomebodyToAsk: boolean; maxDepth: number; maxPerRun: number; approvalGate?: ApprovalGate | undefined; privateShare?: PrivateShareGate | undefined; }'. +server/tests/agent-handoff-endtoend.integration.test.ts:45: error TS2554: Expected 2-3 arguments, but got 1. +server/tests/agent-handoff-endtoend.integration.test.ts:94: error TS2769: No overload matches this call. +server/tests/agent-handoff-tool.test.ts:107: error TS2345: Argument of type '{ desk: HandoffDesk; from: { botId: string; actorId: string; runId: string; threadId: string; depth: number; }; hasSomebodyToAsk: true; maxDepth: number; }' is not assignable to parameter of type '{ desk: HandoffDesk; from: RunAssertion; hasSomebodyToAsk: boolean; maxDepth: number; maxPerRun: number; approvalGate?: ApprovalGate | undefined; privateShare?: PrivateShareGate | undefined; }'. +server/tests/agent-handoff-tool.test.ts:78: error TS2345: Argument of type '{ desk: HandoffDesk; from: { depth: number; botId: string; actorId: string; runId: string; threadId: string; }; hasSomebodyToAsk: true; maxDepth: number; }' is not assignable to parameter of type '{ desk: HandoffDesk; from: RunAssertion; hasSomebodyToAsk: boolean; maxDepth: number; maxPerRun: number; approvalGate?: ApprovalGate | undefined; privateShare?: PrivateShareGate | undefined; }'. +server/tests/agent-handoff.integration.test.ts:34: error TS2554: Expected 2-3 arguments, but got 1. +server/tests/agent-handoff.integration.test.ts:80: error TS2769: No overload matches this call. +server/tests/agent-handoff.test.ts:8: error TS2459: Module '"../src/agents/profile-store"' declares 'AgentProfile' locally, but it is not exported. +server/tests/agent-profile-policy.test.ts:14: error TS2322: Type '{ id: string; name: string; title: string; roleDescription: string; avatarSeed: string; visibility: AgentVisibility; ownerUserId: string | null; ... 7 more ...; hasCallbackToken?: boolean | undefined; }' is not assignable to type 'AgentProfile'. +server/tests/agent-routes.test.ts:36: error TS2322: Type '{ id: string; name: string; title: string; roleDescription: string; avatarSeed: string; visibility: AgentVisibility; ownerUserId: string | null; ... 7 more ...; hasCallbackToken?: boolean | undefined; }' is not assignable to type 'AgentProfile'. +server/tests/agent-routes.test.ts:563: error TS2769: No overload matches this call. +server/tests/agent-routes.test.ts:58: error TS2739: Type '{ list(receivedActor: AgentActor, hidden: boolean | undefined): Promise; get(receivedActor: AgentActor, id: string): Promise<...>; ... 6 more ...; softDelete(receivedActor: AgentActor, id: string): Promise<...>; }' is missing the following properties from type 'AgentProfileStore': issueCallbackToken, revokeCallbackToken, agentForCallbackToken +server/tests/agent-routes.test.ts:703: error TS2769: No overload matches this call. +server/tests/approvals-frontend.test.ts:79: error TS18048: 'message.toolCalls' is possibly 'undefined'. +server/tests/approvals-interactive.integration.test.ts:57: error TS2741: Property 'preconnect' is missing in type '(url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. +server/tests/approvals-native.test.ts:9: error TS2345: Argument of type '(_url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' is not assignable to parameter of type 'typeof fetch'. +server/tests/approvals-policy.test.ts:702: error TS2353: Object literal may only specify known properties, and 'id' does not exist in type '{ kind: "person"; }'. +server/tests/approvals-question-resume.integration.test.ts:250: error TS2741: Property 'resumeWaiting' is missing in type 'Pick' but required in type 'Pick'. +server/tests/approvals-question-resume.integration.test.ts:261: error TS2741: Property 'resumeWaiting' is missing in type 'Pick' but required in type 'Pick'. +server/tests/approvals.test.ts:155: error TS2322: Type 'Promise<{ id: string; ownerUserId: string; botId: string; toolRef: string; effect: string; scope: string; revokedAt: Date | null; createdAt: Date; }[]>' is not assignable to type 'Promise'. +server/tests/approvals.test.ts:296: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. +server/tests/arcade-authorize-status-engine.test.ts:52: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/arcade-confirm-engine.test.ts:41: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/arcade-connection-kind-engine.test.ts:85: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/arcade-connection-kind-engine.test.ts:87: error TS2322: Type 'Engine | null' is not assignable to type 'Engine'. +server/tests/arcade-empty-vendor-structures-engine.test.ts:51: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/arcade-malformed-listing-engine.test.ts:84: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/arcade-pagination-engine.test.ts:218: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/arcade-vendor-error-engine.test.ts:127: error TS2345: Argument of type '{ account: { holder: "person"; userId: string; vendorUserId: string; }; action: string; }' is not assignable to parameter of type '{ account: AccountRef; toolkit: string; returnUrl: string; action?: string | undefined; }'. +server/tests/arcade-vendor-error-engine.test.ts:73: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/attachment-routes.test.ts:1121: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/attachment-routes.test.ts:1159: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/attachment-routes.test.ts:2223: error TS2339: Property 'then' does not exist on type 'Response | Promise'. +server/tests/attachment-routes.test.ts:2224: error TS7006: Parameter 'value' implicitly has an 'any' type. +server/tests/attachment-routes.test.ts:957: error TS2339: Property 'then' does not exist on type 'Response | Promise'. +server/tests/attachment-routes.test.ts:958: error TS7006: Parameter 'value' implicitly has an 'any' type. +server/tests/attachment-store.test.ts:300: error TS2322: Type 'Uint8Array' is not assignable to type 'BlobPart'. +server/tests/attachment-store.test.ts:325: error TS2322: Type 'Uint8Array' is not assignable to type 'BlobPart'. +server/tests/attachment-store.test.ts:350: error TS2322: Type 'Uint8Array' is not assignable to type 'BlobPart'. +server/tests/audit.test.ts:203: error TS2322: Type '(query: AuditEventQuery) => Promise<{ events: { id: string; eventType: string; targetType: string; targetId: string; actorUserId: string; payload: { itemCount: number; }; createdAt: string; }[]; nextCursor: string; }>' is not assignable to type '(query: AuditEventQuery) => Promise<{ events: AuditEvent[]; nextCursor?: string | undefined; }>'. +server/tests/bot-lifecycle-audit.test.ts:184: error TS2769: No overload matches this call. +server/tests/bot-lifecycle-audit.test.ts:301: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. +server/tests/bot-lifecycle-audit.test.ts:315: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. +server/tests/bot-lifecycle-audit.test.ts:326: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. +server/tests/bot-lifecycle-audit.test.ts:335: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. +server/tests/brokered-app-provider.integration.test.ts:15: error TS2305: Module '"../src/plugins/composio"' has no exported member 'ComposioBroker'. +server/tests/brokered-app-provider.integration.test.ts:72: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/channel-routes.test.ts:385: error TS2769: No overload matches this call. +server/tests/channel-routes.test.ts:86: error TS2322: Type '(receivedActor: AgentActor, id: string) => Promise' is not assignable to type '(actor: AgentActor, channelId: string) => Promise'. +server/tests/channel-routes.test.ts:919: error TS2769: No overload matches this call. +server/tests/channel-summary.integration.test.ts:350: error TS2769: No overload matches this call. +server/tests/component-catalogue-entries.test.ts:22: error TS2769: No overload matches this call. +server/tests/component-catalogue.test.ts:21: error TS2769: No overload matches this call. +server/tests/component-decision-functions.test.ts:32: error TS2769: No overload matches this call. +server/tests/component-decision.test.ts:30: error TS2769: No overload matches this call. +server/tests/component-grants-skills-validation.test.ts:5: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. +server/tests/composio-connection-kinds.test.ts:1908: error TS2769: No overload matches this call. +server/tests/composio-connection-kinds.test.ts:1910: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"key" | "none" | "consent" | "unreadable", Cell>'. +server/tests/composio-connection-kinds.test.ts:1924: error TS2769: No overload matches this call. +server/tests/composio-connection-kinds.test.ts:1926: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"unreachable" | "answered" | "nothing" | "complained", Cell>'. +server/tests/composio-connection-kinds.test.ts:327: error TS2769: No overload matches this call. +server/tests/composio-connection-kinds.test.ts:481: error TS2769: No overload matches this call. +server/tests/composio-connections.test.ts:4417: error TS2769: No overload matches this call. +server/tests/composio-connections.test.ts:4898: error TS2345: Argument of type '{ url: string; provenance: string; vendor: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. +server/tests/computer-client.test.ts:139: error TS2769: No overload matches this call. +server/tests/computer-download-routes.test.ts:36: error TS2345: Argument of type '(actor: AuthenticatedActor, botId: string) => Promise' is not assignable to parameter of type 'BotAccessCheck'. +server/tests/computer-gateway.test.ts:1147: error TS2554: Expected 1 arguments, but got 3. +server/tests/computer-gateway.test.ts:519: error TS2353: Object literal may only specify known properties, and 'text' does not exist in type 'WriteFileInput'. +server/tests/computer-gateway.test.ts:841: error TS2769: No overload matches this call. +server/tests/computer-policy.test.ts:235: error TS2345: Argument of type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; key: string; }' is not assignable to parameter of type 'PolicyContext'. +server/tests/computer-policy.test.ts:245: error TS2345: Argument of type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; key: string; }' is not assignable to parameter of type 'PolicyContext'. +server/tests/computer-policy.test.ts:261: error TS2322: Type '{ tool: { name: string; }; bot: { id: string; }; page: { url: string; host: string; }; actor: { id: string; }; element?: { ref: string; role: string; name: string; type?: string; } | undefined; ... 5 more ...; initiator?: { ...; } | undefined; }' is not assignable to type 'PolicyContext'. +server/tests/computer-policy.test.ts:351: error TS2741: Property 'initiator' is missing in type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; intent: "navigate"; }' but required in type 'PolicyContext'. +server/tests/computer-policy.test.ts:409: error TS2741: Property 'initiator' is missing in type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; element: { ref: string; role: string; name: string; type: string; }; key: string; file: { path: string; name: string; extension: string; }; mcp: { ...; }; }' but required in type 'PolicyContext'. +server/tests/computer-policy.test.ts:465: error TS2741: Property 'initiator' is missing in type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; intent: "run_command"; command: string; }' but required in type 'PolicyContext'. +server/tests/computer-policy.test.ts:539: error TS2741: Property 'initiator' is missing in type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; intent: "activate"; element: { ref: string; role: string; name: string; type: string; }; key: string; file: { ...; }; command: string; mcp: { ...; }; }' but required in type 'PolicyContext'. +server/tests/computer-policy.test.ts:551: error TS2741: Property 'initiator' is missing in type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; intent: "read_tool"; element: { ref: string; role: string; name: string; type: string; }; key: string; file: { ...; }; command: string; mcp: { ...; }; }' but required in type 'PolicyContext'. +server/tests/computer-policy.test.ts:640: error TS2345: Argument of type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; intent: "activate"; key: string; element: { ref: string; role: string; name: string; type: string; }; file: { ...; }; command: string; mcp: { ...; }; }' is not assignable to parameter of type 'PolicyContext'. +server/tests/computer-policy.test.ts:662: error TS2345: Argument of type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; key: string; element: { ref: string; role: string; name: string; type: string; }; file: { path: string; name: string; extension: string; }; command: string; mcp: { ...; }; }' is not assignable to parameter of type 'PolicyContext'. +server/tests/computer-provider.test.ts:418: error TS2345: Argument of type '{ provider: "sandbox"; namespace: string; idleAfterMs: number; templateFile: string; }' is not assignable to parameter of type 'ComputerConfig'. +server/tests/copilot.test.ts:1065: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1103: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1176: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1204: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1262: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. +server/tests/copilot.test.ts:1306: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/copilot.test.ts:153: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/copilot.test.ts:157: error TS2739: Type '{ id: string; name: string; type: "built_in"; systemPrompt: string; }[]' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] +server/tests/copilot.test.ts:346: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. +server/tests/copilot.test.ts:387: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. +server/tests/copilot.test.ts:425: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:444: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:477: error TS2322: Type 'Promise<{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]>' is not assignable to type 'Promise'. +server/tests/copilot.test.ts:524: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:529: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:576: error TS2322: Type 'Promise<{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]>' is not assignable to type 'Promise'. +server/tests/copilot.test.ts:697: error TS2345: Argument of type '() => never' is not assignable to parameter of type 'typeof fetch'. +server/tests/credentials.test.ts:139: error TS2345: Argument of type '{ encryptionKey: string; store: { create: () => Promise<{ id: string; revokedAt: null; }>; updateSecret: () => Promise; rotate: (input: { previousCredentialId: string; }) => Promise<...>; revoke: (id: string) => Promise<...>; }; auditStore: { ...; }; }' is not assignable to parameter of type 'CredentialService'. +server/tests/credentials.test.ts:148: error TS2345: Argument of type '{ encryptionKey: string; store: { create: () => Promise<{ id: string; revokedAt: null; }>; updateSecret: () => Promise; rotate: (input: { previousCredentialId: string; }) => Promise<...>; revoke: (id: string) => Promise<...>; }; auditStore: { ...; }; }' is not assignable to parameter of type 'CredentialService'. +server/tests/credentials.test.ts:192: error TS2345: Argument of type '{ encryptionKey: string; store: { create: () => Promise; rotate: () => Promise; revoke: () => Promise; }; auditStore: { insert: (event: unknown) => Promise<...>; }; }' is not assignable to parameter of type 'CredentialService'. +server/tests/credentials.test.ts:705: error TS2345: Argument of type '{ list: () => Promise<{ id: string; kind: "model"; provider: string; keyId: string; metadata: { label: string; }; revokedAt: null; }[]>; }' is not assignable to parameter of type 'CredentialAdminService'. +server/tests/credentials.test.ts:752: error TS2345: Argument of type '{ list: () => Promise; create: (input: CredentialInput) => Promise<{ id: string; kind: "model" | "agent" | "connector" | "mcp" | "mcp_oauth_client" | "mcp_user_token"; provider: string; keyId: string; metadata: Record<...>; revokedAt: null; }>; }' is not assignable to parameter of type 'CredentialAdminService'. +server/tests/delivery-router.test.ts:121: error TS2322: Type '{ findBinding: (_source: string, realm: string, identity: string, address?: string) => Promise<{ ownerUserId: string; channelId: string; agentId: string; threadId: string; id: string; ... 6 more ...; optedOutAt?: Date | null; } | null>; ... 15 more ...; botIdentity: (agentId: string) => Promise<...>; }' is not assignable to type 'RouterStore'. +server/tests/delivery-router.test.ts:204: error TS2322: Type 'Promise' is not assignable to type 'Promise'. +server/tests/delivery-router.test.ts:217: error TS2322: Type '{ findBinding: (_source: string, realm: string, identity: string, address?: string) => Promise<{ ownerUserId: string; channelId: string; agentId: string; threadId: string; id: string; ... 6 more ...; optedOutAt?: Date | null; } | null>; ... 15 more ...; botIdentity: (agentId: string) => Promise<...>; }' is not assignable to type 'RouterStore'. +server/tests/delivery-router.test.ts:310: error TS2322: Type '{ findBinding: (_source: string, realm: string, identity: string, address?: string) => Promise<{ ownerUserId: string; channelId: string; agentId: string; threadId: string; id: string; ... 6 more ...; optedOutAt?: Date | null; } | null>; ... 15 more ...; botIdentity: (agentId: string) => Promise<...>; }' is not assignable to type 'RouterStore'. +server/tests/delivery-router.test.ts:356: error TS2322: Type '{ findBinding: (_source: string, realm: string, identity: string, address?: string) => Promise<{ ownerUserId: string; channelId: string; agentId: string; threadId: string; id: string; ... 6 more ...; optedOutAt?: Date | null; } | null>; ... 15 more ...; botIdentity: (agentId: string) => Promise<...>; }' is not assignable to type 'RouterStore'. +server/tests/delivery-router.test.ts:398: error TS2322: Type 'Promise<{ id: string; ownerUserId: string; token: string; projectId: string; platform: "ios"; enabled: boolean; }[]>' is not assignable to type 'Promise'. +server/tests/delivery-router.test.ts:401: error TS2322: Type '{ findBinding: (_source: string, realm: string, identity: string, address?: string) => Promise<{ ownerUserId: string; channelId: string; agentId: string; threadId: string; id: string; ... 6 more ...; optedOutAt?: Date | null; } | null>; ... 15 more ...; botIdentity: (agentId: string) => Promise<...>; }' is not assignable to type 'RouterStore'. +server/tests/delivery-routes.test.ts:87: error TS2322: Type '(input: Omit) => Promise<{ identity: string; agentId: string; threadId: string; ownerUserId: string; channelId: string; transport: BindingTransport; ... 5 more ...; enabled: boolean; }>' is not assignable to type '(input: Omit) => Promise<{ identity: string; id: string; createdAt: Date; agentId: string; threadId: string; ownerUserId: string; ... 6 more ...; optedOutAt: Date | null; }>'. +server/tests/delivery-routes.test.ts:95: error TS2322: Type 'Promise' is not assignable to type 'Promise<{ transport: BindingTransport; identity: string; realm: string; address: string; mentionId: string | null; enabled: boolean; optedOutAt: Date | null; createdAt: Date; ... 4 more ...; id: string; }>'. +server/tests/delivery-turn.test.ts:68: error TS2322: Type 'string | ContentPart[]' is not assignable to type 'string'. +server/tests/dictation-routes.test.ts:30: error TS2739: Type 'Response & TypedResponse<{ error: string; }, 401, "json">' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] +server/tests/encrypt-sso-config.test.ts:139: error TS2554: Expected 0 arguments, but got 1. +server/tests/encrypt-sso-config.test.ts:74: error TS2554: Expected 0 arguments, but got 1. +server/tests/encrypt-sso-config.test.ts:85: error TS2554: Expected 0 arguments, but got 1. +server/tests/enterprise-controls.integration.test.ts:143: error TS2769: No overload matches this call. +server/tests/enterprise-controls.integration.test.ts:146: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. +server/tests/enterprise-controls.integration.test.ts:147: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. +server/tests/google-drive-rest.test.ts:108: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. +server/tests/google-drive-rest.test.ts:122: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. +server/tests/group-conversations.integration.test.ts:361: error TS2339: Property 'serverId' does not exist on type 'object'. +server/tests/headless-components.integration.test.ts:12: error TS6142: Module '../../app/src/components/gallery/charts' was resolved to 'app/src/components/gallery/charts.tsx', but '--jsx' is not set. +server/tests/headless-components.integration.test.ts:176: error TS2741: Property 'preconnect' is missing in type '(url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. +server/tests/headless-components.integration.test.ts:265: error TS7006: Parameter 'entry' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:282: error TS2352: Conversion of type 'BaseEvent' to type '{ delta: string; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. +server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'description' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'kind' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'name' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'title' implicitly has an 'any' type. +server/tests/headless-components.test.ts:104: error TS7006: Parameter 'entry' implicitly has an 'any' type. +server/tests/headless-components.test.ts:10: error TS6142: Module '../../app/src/components/gallery/quote' was resolved to 'app/src/components/gallery/quote.tsx', but '--jsx' is not set. +server/tests/headless-components.test.ts:11: error TS6142: Module '../../app/src/components/gallery/table' was resolved to 'app/src/components/gallery/table.tsx', but '--jsx' is not set. +server/tests/headless-components.test.ts:141: error TS2307: Cannot find module 'react' or its corresponding type declarations. +server/tests/headless-components.test.ts:144: error TS2307: Cannot find module 'react-dom/server' or its corresponding type declarations. +server/tests/headless-components.test.ts:5: error TS6142: Module '../../app/src/components/gallery/activity' was resolved to 'app/src/components/gallery/activity.tsx', but '--jsx' is not set. +server/tests/headless-components.test.ts:6: error TS6142: Module '../../app/src/components/gallery/cards' was resolved to 'app/src/components/gallery/cards.tsx', but '--jsx' is not set. +server/tests/headless-components.test.ts:7: error TS6142: Module '../../app/src/components/gallery/charts' was resolved to 'app/src/components/gallery/charts.tsx', but '--jsx' is not set. +server/tests/headless-components.test.ts:8: error TS6142: Module '../../app/src/components/gallery/decisions' was resolved to 'app/src/components/gallery/decisions.tsx', but '--jsx' is not set. +server/tests/headless-components.test.ts:9: error TS6142: Module '../../app/src/components/gallery/form' was resolved to 'app/src/components/gallery/form.tsx', but '--jsx' is not set. +server/tests/headless-tools.test.ts:125: error TS2322: Type '(context: { ownerUserId: string; agentId: string; initiator: { kind: string; id?: string | undefined; }; }) => Promise' is not assignable to type '(input: { ownerUserId: string; agentId: string; initiator: AuditInitiator; threadId: string; runId: string; }) => Promise'. +server/tests/headless-tools.test.ts:296: error TS2741: Property 'preconnect' is missing in type '(url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. +server/tests/health.test.ts:140: error TS2345: Argument of type '{ handler: () => Response; }' is not assignable to parameter of type 'AuthService'. +server/tests/identity-provider-audit.test.ts:28: error TS2322: Type 'Promise' is not assignable to type 'Promise'. +server/tests/identity-provider-audit.test.ts:95: error TS7006: Parameter 'event' implicitly has an 'any' type. +server/tests/intelligence-authentication.test.ts:113: error TS2769: No overload matches this call. +server/tests/intelligence-authentication.test.ts:97: error TS2769: No overload matches this call. +server/tests/learning-ensure-container.test.ts:34: error TS2322: Type '{ probes: number; probe: () => Promise; project: () => Promise<{ organizationSlug: string; projectSlug: string; }>; }' is not assignable to type 'KeyIntelligence & { probes: number; }'. +server/tests/learning-routes.test.ts:61: error TS2322: Type '{ "content-type": string; origin: string; } | { "content-type": string; origin?: undefined; }' is not assignable to type 'HeadersInit | undefined'. +server/tests/mcp-grant-removal.integration.test.ts:35: error TS2322: Type 'Promise' is not assignable to type 'Promise'. +server/tests/mcp-grant-removal.integration.test.ts:61: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/memory-runtime.test.ts:19: error TS2344: Type 'BaseEvent' does not satisfy the constraint 'readonly unknown[]'. +server/tests/memory-store.integration.test.ts:170: error TS2769: No overload matches this call. +server/tests/model-providers.test.ts:353: error TS2345: Argument of type '(input: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' is not assignable to parameter of type 'typeof fetch'. +server/tests/model-providers.test.ts:41: error TS2769: No overload matches this call. +server/tests/passwords-sign-in.test.ts:864: error TS2353: Object literal may only specify known properties, and 'code' does not exist in type '{ username?: string | undefined; password: string; save?: boolean | undefined; }'. +server/tests/people-routes.test.ts:84: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/plan-model.test.ts:156: error TS2769: No overload matches this call. +server/tests/plan-model.test.ts:271: error TS2345: Argument of type '(id: string) => Promise<{ name: string; description: string; parameters: ZodObject<{ query: ZodString; }, $strip>; ref: { kind: string; serverId: string; toolName: string; }; execute: (args: unknown) => Promise<...>; }[]>' is not assignable to parameter of type 'LoadToolsForBot'. +server/tests/plugin-call-args-validation.test.ts:5: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. +server/tests/plugin-catalogue.test.ts:92: error TS2531: Object is possibly 'null'. +server/tests/plugin-curated-credential.integration.test.ts:37: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-grants-validation.test.ts:4: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. +server/tests/plugin-oauth-callback.test.ts:157: error TS2769: No overload matches this call. +server/tests/plugin-routes.integration.test.ts:32: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-routes.test.ts:1071: error TS2769: No overload matches this call. +server/tests/plugin-routes.test.ts:125: error TS2739: Type '{ id: string; title: string; vendor: string; url: string; summary: string; docsUrl: string; provenance: string; hasCredential: false; toolsRefreshedAt: null; lastError: null; addedBy: null; dynamicClient: false; authScheme: null; tools: never[]; withdrawn: never[]; }' is missing the following properties from type 'ServerRecord': logo, accountMode, sharedVendorConnected +server/tests/plugin-routes.test.ts:1775: error TS2322: Type '(serverId: string) => Promise<{ id: string; title: string; url: string; authScheme: string | null; accountMode?: "shared"; } | undefined>' is not assignable to type '(serverId: string) => Promise'. +server/tests/plugin-routes.test.ts:1784: error TS2322: Type '(serverId: string, actorId: string) => Promise<{ refusal: string; readonly ref?: undefined; title?: undefined; mode?: undefined; } | { ref: { holder: "deployment"; vendorUserId: string; userId?: undefined; }; title: string; mode: "shared"; refusal?: undefined; } | { ...; }>' is not assignable to type '(serverId: string, actorId: string) => Promise<{ ref: AccountRef; title: string; mode: "personal" | "shared"; appId: string; } | { refusal: string; }>'. +server/tests/plugin-servers-validation.test.ts:4: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. +server/tests/plugin-store.integration.test.ts:165: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-store.integration.test.ts:4072: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-store.integration.test.ts:4979: error TS2339: Property 'payload' does not exist on type '{ eventType: string; targetId?: string | undefined; }'. +server/tests/plugin-store.integration.test.ts:5755: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. +server/tests/plugin-store.integration.test.ts:5764: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. +server/tests/plugin-store.integration.test.ts:6128: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-store.integration.test.ts:6257: error TS2556: A spread argument must either have a tuple type or be passed to a rest parameter. +server/tests/plugin-store.integration.test.ts:6264: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-store.integration.test.ts:6399: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-store.integration.test.ts:6548: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-store.integration.test.ts:6614: error TS2739: Type '{ grantIsLocal: true; authProviderOf: () => Promise; authorizations: null; grantsFor: () => Promise; listApps: () => Promise; ensureAuthConfig: (config: { ...; }) => Promise<...>; ... 4 more ...; accountName: () => Promise<...>; }' is missing the following properties from type 'ConnectedAppBroker': connectionFields, connectWithFields, revokeAccount +server/tests/plugin-store.integration.test.ts:6721: error TS2345: Argument of type '{ slug: string; title: string; by: string; }' is not assignable to parameter of type '{ slug: string; title: string; logo?: string | null | undefined; by: string; connection: BrokerConnection; }'. +server/tests/plugin-store.integration.test.ts:6806: error TS2322: Type '(request: { toolkit: string; name: string; }) => Promise' is not assignable to type '(config: { toolkit: string; name: string; connection: BrokerConnection; }) => Promise'. +server/tests/plugin-store.integration.test.ts:7008: error TS2345: Argument of type 'string | null' is not assignable to parameter of type 'string'. +server/tests/plugin-store.integration.test.ts:7332: error TS2322: Type '() => Promise' is not assignable to type '(config: { toolkit: string; name: string; }) => Promise'. +server/tests/plugin-store.integration.test.ts:7335: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. +server/tests/plugin-store.integration.test.ts:7339: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. +server/tests/plugin-user-credential.integration.test.ts:98: error TS2322: Type '{ readSecret: (id: string) => Promise<{ encryptedValue: string; revokedAt: Date | null; }>; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise<...>; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/policy-dry-run.test.ts:24: error TS2739: Type '{ id: string; actorUserId: null; eventType: string; targetType: string; targetId: string; createdAt: string; payload: Record; }' is missing the following properties from type 'AuditEvent': initiatorKind, initiatorId +server/tests/remote-approval-context.test.ts:11: error TS2345: Argument of type '(input: RunAgentInput) => Observable<{ type: string; threadId: string; runId: string; } | { type: string; snapshot: { real: string; }; } | { type: string; toolCallId: string; toolCallName: string; parentMessageId: string; } | { ...; } | { ...; } | { ...; } | { ...; }>' is not assignable to parameter of type '(input: RunAgentInput) => Observable'. +server/tests/remote-bot-handoff.integration.test.ts:128: error TS2769: No overload matches this call. +server/tests/remote-bot-handoff.integration.test.ts:47: error TS2322: Type 'Promise' is not assignable to type 'Promise'. +server/tests/remote-bot-handoff.integration.test.ts:62: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/remote-bot-handoff.integration.test.ts:81: error TS2554: Expected 2-3 arguments, but got 1. +server/tests/remote-coordination.integration.test.ts:42: error TS2769: No overload matches this call. +server/tests/remove-server-foreign-provenance.test.ts:112: error TS2769: No overload matches this call. +server/tests/responsibility-triggers.test.ts:529: error TS2353: Object literal may only specify known properties, and 'agentId' does not exist in type 'ResolvedTrigger'. +server/tests/routes-vendor-vocabulary.test.ts:225: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:227: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:229: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:234: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:239: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:244: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:249: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:254: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:260: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:262: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:264: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:266: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:270: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routes-vendor-vocabulary.test.ts:274: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. +server/tests/routine-routes.test.ts:45: error TS2739: Type '{ create(): Promise; listFor(ownerUserId: string): Promise; update(): Promise; remove(ownerUserId: string, id: string): Promise<...>; ... 9 more ...; consecutiveFailures(): Promise<...>; }' is missing the following properties from type 'RoutineStore': startManualRun, listRuns, reapAbandonedRuns, markUnschedulable +server/tests/routine-run-now.test.ts:80: error TS2322: Type '() => Promise<({ id: string; status: null; startedAt: Date; finishedAt: null; error: null; } | { id: string; status: "failed"; startedAt: Date; finishedAt: Date; error: string; })[]>' is not assignable to type '(ownerUserId: string, id: string, limit?: number | undefined) => Promise'. +server/tests/routine-runner.test.ts:110: error TS2739: Type '{ create: () => never; list: () => never; setPinned: () => never; markRead: () => never; softDelete: () => never; get(actor: AgentActor, channelId: string): Promise; recordActivity(actor: AgentActor, channelId: string, activity: ChannelActivity): Promise<...>; }' is missing the following properties from type 'ChannelStore': direct, signalBusy, signalChannelBusy +server/tests/routine-runner.test.ts:39: error TS2741: Property 'lastMessageAt' is missing in type '{ id: string; name: string; agentIds: string[]; threadId: string; active: true; }' but required in type 'AgentChannel'. +server/tests/routine-runner.test.ts:78: error TS2740: Type '{ create: () => never; listFor: () => never; update: () => never; remove: () => never; dueRoutines: () => never; advanceNextRun: () => never; insertRun: () => never; runContext(runId: string): Promise; finishRun(runId: string, status: RoutineRunOutcome, error: string | undefined): Promise<....' is missing the following properties from type 'RoutineStore': startManualRun, listRuns, routineForFiring, reapAbandonedRuns, and 3 more. +server/tests/routing-routes.test.ts:106: error TS2345: Argument of type '(input: { id: string; name: string; roleDescription: string; visibility: AgentVisibility; }) => AgentProfile' is not assignable to parameter of type '(value: { id: string; name: string; roleDescription: string; visibility: string; }, index: number, array: { id: string; name: string; roleDescription: string; visibility: string; }[]) => AgentProfile'. +server/tests/routing-service.test.ts:500: error TS2339: Property 'names' does not exist on type 'CoworkerRouteResult'. +server/tests/routing-service.test.ts:501: error TS2339: Property 'names' does not exist on type 'CoworkerRouteResult'. +server/tests/routing-service.test.ts:594: error TS2339: Property 'names' does not exist on type 'CoworkerRouteResult'. +server/tests/routing-service.test.ts:595: error TS2339: Property 'names' does not exist on type 'CoworkerRouteResult'. +server/tests/schema.test.ts:196: error TS2677: A type predicate's type must be assignable to its parameter's type. +server/tests/schema.test.ts:201: error TS2345: Argument of type '(table: T) => T["_"]["name"]' is not assignable to parameter of type '(value: PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ......'. +server/tests/schema.test.ts:202: error TS2345: Argument of type '(table: T) => T["_"]["name"]' is not assignable to parameter of type '(value: PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ......'. +server/tests/schema.test.ts:203: error TS2345: Argument of type '(table: T) => T["_"]["name"]' is not assignable to parameter of type '(value: PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ......'. +server/tests/schema.test.ts:206: error TS2345: Argument of type 'PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ...; update...' is not assignable to parameter of type 'PgTable'. +server/tests/schema.test.ts:208: error TS2345: Argument of type 'PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ...; update...' is not assignable to parameter of type 'Table>>'. +server/tests/server-side-tools.integration.test.ts:16: error TS2307: Cannot find module '../src/policy/engine' or its corresponding type declarations. +server/tests/server-side-tools.integration.test.ts:47: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/settings-instructions-routes.test.ts:236: error TS2339: Property 'then' does not exist on type 'Response | Promise'. +server/tests/settings-instructions-routes.test.ts:236: error TS7006: Parameter 'response' implicitly has an 'any' type. +server/tests/settings-instructions-routes.test.ts:241: error TS2339: Property 'then' does not exist on type 'Response | Promise'. +server/tests/settings-instructions-routes.test.ts:241: error TS7006: Parameter 'response' implicitly has an 'any' type. +server/tests/settings-instructions-routes.test.ts:305: error TS2339: Property 'then' does not exist on type 'Response | Promise'. +server/tests/settings-instructions-routes.test.ts:305: error TS7006: Parameter 'response' implicitly has an 'any' type. +server/tests/settings-instructions-routes.test.ts:314: error TS2339: Property 'then' does not exist on type 'Response | Promise'. +server/tests/settings-instructions-routes.test.ts:314: error TS7006: Parameter 'response' implicitly has an 'any' type. +server/tests/skill-ownership.integration.test.ts:29: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/skill-tools.integration.test.ts:35: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/skill-uninstall-grants.integration.test.ts:26: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/store-scheme-sites.test.ts:24: error TS2305: Module '"../src/plugins/composio"' has no exported member 'ComposioBroker'. +server/tests/store-scheme-sites.test.ts:82: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/support/constraints.ts:15: error TS2307: Cannot find module '../src/db/client' or its corresponding type declarations. +server/tests/tenant-package.test.ts:1440: error TS2698: Spread types may only be created from object types. +server/tests/tenant-package.test.ts:1518: error TS2698: Spread types may only be created from object types. +server/tests/thread-routes.test.ts:22: error TS2769: No overload matches this call. +server/tests/tool-selection.integration.test.ts:248: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/tool-selection.integration.test.ts:251: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/tool-selection.integration.test.ts:454: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/tool-selection.integration.test.ts:648: error TS2345: Argument of type '() => string | undefined' is not assignable to parameter of type 'SignRun'. +server/tests/tool-selection.integration.test.ts:875: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/tool-selection.integration.test.ts:906: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/tool-selection.integration.test.ts:936: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/user-preferences.integration.test.ts:60: error TS2769: No overload matches this call. +server/tests/voice-routes.test.ts:13: error TS2741: Property 'pinned' is missing in type '{ id: string; name: string; title: string; roleDescription: string; avatarSeed: string; visibility: "public"; ownerUserId: null; systemOwned: false; hidden: false; deletedAt: null; endpoint: null; hasAuth: false; hasCallbackToken: false; }' but required in type 'AgentProfile'. +server/tests/voice-routes.test.ts:161: error TS2739: Type 'Response & TypedResponse<{ error: string; }, 401, "json">' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] +server/tests/voice-routes.test.ts:79: error TS2739: Type 'Response & TypedResponse<{ error: string; }, 401, "json">' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] +server/tests/voice-session-preview.integration.test.ts:88: error TS2769: No overload matches this call. +server/tests/voice-session-routes.test.ts:162: error TS2739: Type 'Response & TypedResponse<{ error: string; }, 401, "json">' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] +server/tests/voice-session-store.integration.test.ts:123: error TS2345: Argument of type '`page-${string}`' is not assignable to parameter of type '`${string}-${string}-${string}-${string}-${string}`'. +server/tests/voice-sessions.test.ts:43: error TS2741: Property 'preconnect' is missing in type '(_url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. +server/tests/voice-sessions.test.ts:85: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. diff --git a/server/tsconfig.tests.json b/server/tsconfig.tests.json new file mode 100644 index 000000000..cccb285e9 --- /dev/null +++ b/server/tsconfig.tests.json @@ -0,0 +1,19 @@ +{ + // The test tree, type-checked. `server/tsconfig.json` deliberately leaves `tests` out — see the + // comment there — so `bun run typecheck` reports CLEAN while real type errors sit in the suite: + // broken `import type` paths that silently degrade a value to `any`, stubs missing methods the + // interface requires, `number | undefined` passed where `number` is required. bun erases types + // rather than checking them, so running the suite never catches any of it. + // + // This config EXTENDS the project's own, so the test tree is checked under exactly the options + // `src` is checked under. Checking these files with an ad-hoc `tsc` invocation and hand-written + // flags is unsound: it drags `src` in under the wrong options and emits a flood of errors that + // are artefacts of the flags rather than defects. + // + // `include` is not merged by `extends`, it is replaced, so the inherited entries are repeated + // here with `tests` added. The errors that already exist are frozen in + // `server/tests/typecheck-baseline.txt`, and the gate fails only on an error that is not in it — + // see `scripts/typecheck-tests.ts`. + "extends": "./tsconfig.json", + "include": ["src", "scripts", "../scripts/composio-smoke.ts", "tests"] +} From 061a77b5713fb9eb26f5139ca919f64c288c1d8b Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 02:39:48 +0200 Subject: [PATCH 161/232] test(contract): one home per named decision, and the keyed lookup extracted into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CL-6, strictly additive plus a no-op extract. The prototype-key crash was fixed once, with a `Map`, on the connected-accounts page, and the reason was written in a comment there. Three other tables already used a `Map` for the same reason and each explained it again; a fourth went back to an object literal. So the read is a named function now — `keyedLookup(map, key)` in `app/src/lib/lookup.ts` — taking a `ReadonlyMap`, so handing it an object literal is a type error. The four `Map` sites call it. Each rewrite is `map.get(k)` -> `keyedLookup(map, k)` with the `?? fallback` left exactly where it was, because what a missing key should draw is the page's decision and not the helper's. The UNFIXED object-literal site at `admin/plugins/index.tsx` is deliberately NOT converted: that is a behaviour change and belongs to the fix cycle (G5). And the DRY sweep's report is made binding. `server/tests/sole-definition.test.ts` asserts each tracked decision has exactly one definition in the repo's sources — scanning through the shared `source-scan` primitives, so comments are blanked and a wrapped definition is still one line. The duplicates standing today (`brokeredProvenance` x3 counting the `isBrokered` re-spelling, `scopesOf` x4) are recorded with the finding that owns each, so the guard is green on arrival and goes red both when a fifth spelling lands and when a collapse leaves the list stale. Positive control: a second `keyedLookup` planted in `server/src/plugins/ access.ts` turned it red naming that file, and was removed. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/gallery/activity.tsx | 16 +- app/src/lib/copilot/gallery-registry.ts | 3 +- app/src/lib/lookup.ts | 35 +++ .../settings/connected-accounts/index.tsx | 12 +- server/tests/sole-definition.test.ts | 210 ++++++++++++++++++ 5 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 app/src/lib/lookup.ts create mode 100644 server/tests/sole-definition.test.ts diff --git a/app/src/components/gallery/activity.tsx b/app/src/components/gallery/activity.tsx index e1fe05c38..1b10598e7 100644 --- a/app/src/components/gallery/activity.tsx +++ b/app/src/components/gallery/activity.tsx @@ -5,6 +5,7 @@ import { callComponentFunction } from "@/lib/components/queries"; import { useActiveBotId } from "@/lib/copilot/active-bot"; import { useConversation } from "@/lib/copilot/conversation"; import type { GalleryComponent } from "@/lib/copilot/gallery-registry"; +import { keyedLookup } from "@/lib/lookup"; import { GalleryFrame } from "./frame"; import { seriesColour } from "./palette"; @@ -36,10 +37,11 @@ type ActivityArgs = z.infer; /** * Which server-side function each report reads. Not the model's choice. * - * A Map because the key is a string somebody else chose: an object lookup answers `toString` or - * `constructor` with a member inherited from `Object.prototype`, which is truthy and so walks - * straight past the `functionName ?` guards below — handing a function to the grant check as the - * data function this component reads, and to `callComponentFunction` as a function name. + * A Map, read through `keyedLookup`, because the key is a string somebody else chose: an object + * lookup answers `toString` or `constructor` with a member inherited from `Object.prototype`, + * which is truthy and so walks straight past the `functionName ?` guards below — handing a + * function to the grant check as the data function this component reads, and to + * `callComponentFunction` as a function name. */ const FUNCTION_FOR: ReadonlyMap = new Map([ ["activity", "botActivity"], @@ -68,7 +70,7 @@ export function ActivityReportCard({ const conversation = useConversation(); const [state, setState] = useState({ status: "reading" }); - const functionName = report ? FUNCTION_FOR.get(report) : undefined; + const functionName = report ? keyedLookup(FUNCTION_FOR, report) : undefined; useEffect(() => { // Nothing to read until the arguments have finished streaming in. @@ -291,7 +293,9 @@ export const GALLERY: GalleryComponent[] = [ "The report is on screen for the person, filled with figures read from this deployment. You were not given the figures.", reads: (args) => { const report = typeof args.report === "string" ? args.report : undefined; - const functionName = report ? FUNCTION_FOR.get(report) : undefined; + const functionName = report + ? keyedLookup(FUNCTION_FOR, report) + : undefined; return functionName ? [functionName] : []; }, }, diff --git a/app/src/lib/copilot/gallery-registry.ts b/app/src/lib/copilot/gallery-registry.ts index be999ea30..b5b1eefe6 100644 --- a/app/src/lib/copilot/gallery-registry.ts +++ b/app/src/lib/copilot/gallery-registry.ts @@ -1,5 +1,6 @@ import type { useFrontendTool } from "@copilotkit/react-core/v2"; import type { ReactElement } from "react"; +import { keyedLookup } from "@/lib/lookup"; /** * Discover gallery components from files that export `GALLERY`; deployment state owns publication, @@ -118,5 +119,5 @@ const BY_NAME: ReadonlyMap = new Map( * name arriving from the server is not a promise that anything here can draw it. */ export function galleryComponent(name: string): GalleryComponent | undefined { - return BY_NAME.get(name); + return keyedLookup(BY_NAME, name); } diff --git a/app/src/lib/lookup.ts b/app/src/lib/lookup.ts new file mode 100644 index 000000000..a5dfae680 --- /dev/null +++ b/app/src/lib/lookup.ts @@ -0,0 +1,35 @@ +// app/src/lib/lookup.ts +/** + * ONE home for "read a table by a key somebody else chose". + * + * WHY THIS EXISTS. A catalogue slug, a component name, a report id and a verifier's status code all + * arrive from outside this build, and every one of them was at some point used to index a plain + * object literal. An object literal indexed that way answers `toString`, `constructor`, `valueOf` + * and `__proto__` with a member inherited from `Object.prototype` — truthy, so it walks straight + * past the `?? fallback` and the `if (hit)` guard that were supposed to catch "not in the table", + * and hands the caller a Function where it expected a component, a sentence or a row. That crash + * was fixed once, in the connected-accounts page, with a `Map`; the fix was documented there and + * nowhere else, and the next table went back to an object literal. + * + * So the prototype-safe read is a named function rather than a habit: `keyedLookup(TABLE, key)` + * reads the same whichever page writes it, greps as one symbol, and has exactly one definition — + * `server/tests/sole-definition.test.ts` asserts that it does. + * + * A `Map` answers only with what was PUT IN IT. That is the whole mechanism; this function adds + * nothing to it but a name. Taking a `ReadonlyMap` is the point — passing an object literal is a + * type error, which is the only way a rule like this survives the next table. + */ + +/** + * The value stored at `key`, or `undefined` when the table holds no such key. + * + * Behaviourally identical to `map.get(key)`, deliberately: the callers below each keep their own + * `?? fallback`, because what a missing key should draw or say is the page's decision and not this + * function's. + */ +export function keyedLookup( + map: ReadonlyMap, + key: K, +): V | undefined { + return map.get(key); +} diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 9f0a99257..ef9241008 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -36,6 +36,7 @@ import { ItemTitle, } from "@/components/ui/item"; import { linkedAccountsQueryOptions } from "@/lib/identity/queries"; +import { keyedLookup } from "@/lib/lookup"; import { connectionsQueryOptions, type PluginServer, @@ -156,7 +157,9 @@ export const AUTHORIZATION_NOTICES: ReadonlyMap = new Map([ export function authorizationNoticeFor( authorized: string | undefined, ): string | null { - return authorized ? (AUTHORIZATION_NOTICES.get(authorized) ?? null) : null; + return authorized + ? (keyedLookup(AUTHORIZATION_NOTICES, authorized) ?? null) + : null; } /** The same marks the admin connector list uses: these are the same vendors seen from your side. */ @@ -170,8 +173,11 @@ const MARKS: ReadonlyMap< /* A Map for the same reason the notices above are one: a lookup answers with what was put in it and * nothing a plain object would have inherited, so a catalogue key called `toString` draws the - * fallback mark rather than handing React `Object.prototype.toString` to render as a component. */ -const markFor = (key: string) => MARKS.get(key) ?? IconPlug; + * fallback mark rather than handing React `Object.prototype.toString` to render as a component. + * The read goes through `keyedLookup` so the rule has one home rather than one comment per page; + * see `app/src/lib/lookup.ts`. The `?? IconPlug` stays here, because what a missing key draws is + * this page's decision. */ +const markFor = (key: string) => keyedLookup(MARKS, key) ?? IconPlug; /** * Whether a row came through a broker. A deployment only ever holds rows from one provider, so the diff --git a/server/tests/sole-definition.test.ts b/server/tests/sole-definition.test.ts new file mode 100644 index 000000000..ed3648070 --- /dev/null +++ b/server/tests/sole-definition.test.ts @@ -0,0 +1,210 @@ +// server/tests/sole-definition.test.ts +/** + * Guard: a decision that has been named once has EXACTLY ONE DEFINITION in this repo. + * + * WHY THIS EXISTS. The recurring defect across both review rounds is not a wrong line; it is a + * right line that never reached its siblings. A fix lands at the site the reviewer pointed at, the + * same decision stays spelled out two files away, and the next round finds it again. A round-1 DRY + * sweep already REPORTED exactly this — `scopesOf` written four times, the brokered-provenance test + * written in three places — and a report is not a check: nothing failed, so nothing changed, and + * the duplicates are all still here to be read below. + * + * So the report is made BINDING. Each entry in {@link TRACKED} names a decision and every spelling + * it currently answers to; the scan collects every definition of those spellings across the repo's + * sources and compares the list against what is recorded. A symbol with no entry in + * {@link KNOWN_DUPLICATES} must have exactly one definition. A symbol with an entry must have + * exactly the definitions recorded there — not fewer and not more. Both directions matter: + * + * - MORE fails the day somebody re-spells a tracked decision in a fifth file, which is the defect + * this exists to stop. + * - FEWER fails the day the fix cycle collapses a duplicate, which is not a regression but does + * mean the list is now stale. The failure says so and names the site to delete. That is how the + * list burns down to empty instead of quietly outliving the debt it records. + * + * LANDING GREEN IS DELIBERATE. Every duplicate standing today is recorded in {@link KNOWN_DUPLICATES} + * with the finding that owns it, so this guard is green on arrival and collapsing the duplicates + * stays with the fix cycle where a reviewer can see each merge argued for. A guard that lands red + * gets skipped; a guard that lands green and goes red on the next re-spelling gets read. + * + * WHAT THE PATTERN SEES, AND WHAT IT DOES NOT. A definition is a `function`, `const`, `let`, `var` + * or `class` binding of the name, through the shared scanner so comments are blanked (a `{@link + * scopesOf}` in a docblock is prose, not a home) and a prettier-wrapped definition is still one + * line. It does NOT see a name bound as an object property (`{ scopesOf: (x) => … }`), a method, or + * a re-export alias: those spellings do not occur for the tracked names today, and widening the + * pattern speculatively would buy false positives on every unrelated `scopesOf:` key. The names + * list is the escape hatch instead — a decision re-spelled under a NEW name, like `isBrokered` for + * `brokeredProvenance`, is added as an alias of the decision it duplicates, which is the only + * reason an alias list exists at all. + * + * TEST FILES ARE NOT SCANNED. A `const lookup = async (hash) => …` inside a test is a fixture with + * a short life and a local meaning, not a second home for a production decision. + */ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { logicalLines, sourceFiles } from "./support/source-scan"; + +const REPO = join(import.meta.dir, "../.."); + +/** The source trees a production decision can live in. Tests are excluded below, by file name. */ +const ROOTS = [ + "app/src", + "server/src", + "shared", + "worker", + "scripts", + "desktop/src", + "mobile/src", +]; + +/** + * A decision, every name it is currently spelled under, and — for one that is NOT in + * {@link KNOWN_DUPLICATES} — the single file that is its home. + * + * The home is written down rather than inferred so the failure reads as a diff of two file lists: + * "expected [lookup.ts], received [lookup.ts, access.ts]" names the offending file, where a bare + * length assertion would only say "expected 1, received 2" and leave the reader to go find it. + */ +type Tracked = { decision: string; names: string[]; home?: string }; + +const TRACKED: Tracked[] = [ + /* + * Is this row's provenance a broker's? Spelled as a named predicate twice and open-coded as the + * same two-arm literal comparison a third time, under a different name, on the page that lists + * the accounts those rows produce. + */ + { + decision: "brokered-provenance", + names: ["brokeredProvenance", "isBrokered"], + }, + /* + * The scopes a grant/tool/config carries, read out of a shape whose field is not guaranteed. + * Four definitions, three of them in the plugins directory. + */ + { decision: "scopes-of", names: ["scopesOf"] }, + /* + * The prototype-safe keyed read extracted by CL-6. Tracked from the day it lands, so the table + * lookup never acquires a second home the way the two above did. + */ + { + decision: "keyed-lookup", + names: ["keyedLookup"], + home: "app/src/lib/lookup.ts", + }, +]; + +/** + * The duplicates standing when this guard landed, each with the finding that owns collapsing it. + * + * Keyed by decision. The list is the debt, written down: when a finding below is fixed, its sites + * come out of this list in the same commit, and the guard goes red if they do not. + */ +const KNOWN_DUPLICATES: Record< + string, + { finding: string; why: string; sites: string[] } +> = { + "brokered-provenance": { + finding: "round-1 DRY sweep (`brokeredProvenance` defined 2x) + G-series", + why: "One predicate for the app and one for the server, plus the same two-arm literal open-coded as `isBrokered` on the connected-accounts page; collapsing them is a cross-package move the fix cycle owns.", + sites: [ + "app/src/lib/plugins/provider-vocabulary.ts", + "app/src/routes/_authed/settings/connected-accounts/index.tsx", + "server/src/plugins/access.ts", + ], + }, + "scopes-of": { + finding: "round-1 DRY sweep (`scopesOf` defined 4x)", + why: "Four readers of the same `scopes` field with four input shapes; merging them means agreeing one shape, which is a behavioural decision and stays in the fix cycle.", + sites: [ + "app/src/lib/plugins/grants.ts", + "server/src/plugins/arcade-adapter.ts", + "server/src/plugins/arcade.ts", + "server/src/plugins/composio-adapter.ts", + ], + }, +}; + +/** A binding of `name`, not a call of it and not a mention of it in prose. */ +function definitionPattern(name: string): RegExp { + return new RegExp( + `(?:^|[^.\\w$])(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:function\\s*\\*?|const|let|var|class)\\s+${name}(?![\\w$])`, + ); +} + +/** Not a test, not a type declaration: a file a production decision can be defined in. */ +function isSource(path: string): boolean { + return !/\.(test|spec)\.tsx?$|\.d\.ts$/.test(path); +} + +/** + * Every source file in the repo where one of `names` is bound, one entry per binding. + * + * PATHS, NOT `path:line`, on purpose. A list carrying line numbers goes red when somebody rewraps + * a comment three functions above a definition, and a guard that fails for a reason unrelated to + * what it guards is one people learn to re-baseline without reading. A second definition in the + * SAME file still shows up, as that path listed twice. + */ +function definitionsOf(names: string[]): string[] { + const patterns = names.map(definitionPattern); + const out: string[] = []; + for (const root of ROOTS) { + const full = join(REPO, root); + if (!existsSync(full)) continue; + for (const file of sourceFiles(full, [".ts", ".tsx"])) { + if (!isSource(file)) continue; + for (const { text } of logicalLines(readFileSync(file, "utf8"))) { + if (patterns.some((re) => re.test(text))) { + out.push(file.slice(REPO.length + 1)); + } + } + } + } + return out.sort(); +} + +for (const { decision, names, home } of TRACKED) { + test(`"${decision}" has one definition, or exactly the duplicates on record`, () => { + const found = definitionsOf(names); + const known = KNOWN_DUPLICATES[decision]; + if (known) { + // Recorded debt. Equality both ways: a new re-spelling fails, and so does a collapse that + // did not also delete the site from the list above. + expect(found).toEqual([...known.sites].sort()); + return; + } + // No recorded debt: one home, and the diff names any file that became a second one. + expect(found).toEqual(home ? [home] : []); + }); +} + +/** + * Every spelling the name scan must catch, and every one it must not. + * + * The guard's own positive control plants a duplicate in a real file, which proves the walk reaches + * it. This proves the other half: that what the walk hands the pattern is read as a definition when + * it is one and as a call when it is not. Without this, the guard could be green because the + * pattern matches nothing at all. + */ +test("a definition is matched, and a call or a mention is not", () => { + const re = definitionPattern("keyedLookup"); + const definitions = [ + "function keyedLookup(map, key) {", + "export function keyedLookup(map, key) {", + " const keyedLookup = (map, key) => map.get(key);", + "export const keyedLookup = (m, k) => m.get(k);", + "let keyedLookup;", + "var keyedLookup = 1;", + "class keyedLookup {}", + "export async function keyedLookup(m, k) {", + ]; + for (const line of definitions) expect(re.test(line)).toBe(true); + const notDefinitions = [ + " return keyedLookup(MARKS, key) ?? IconPlug;", + 'import { keyedLookup } from "@/lib/lookup";', + "const mark = utils.keyedLookup(MARKS, key);", + " * see {@link keyedLookup} for why this is a Map", + "const keyedLookupTable = new Map();", + "function keyedLookupTable() {}", + ]; + for (const line of notDefinitions) expect(re.test(line)).toBe(false); +}); From 332631cdd8b206987fa09286ac6c512f63787915 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 02:47:40 +0200 Subject: [PATCH 162/232] test(guards): the six assertion shapes that cannot fail, read off the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review rounds found some two dozen assertions that pass whatever the code does, and nothing in the repo could have caught any of them: server/tests is outside tsc, the lint set carries no floating-assertion rule, and the red-green protocol proves one assertion can fail, never its siblings. Six of those families are visible in the text with nothing executed: a .rejects or .resolves neither awaited nor returned; a `?.` in an expect subject under a matcher that `undefined` itself satisfies; a toEqual whose expected literal holds an `undefined` value, which bun 1.4.2 ignores (measured, with the Object.keys replacer claim, in a throwaway spec); a test every assertion of which sits inside an `if` with no throw outside the branches; a module-level recorder a stub writes and no line ever reads; and JSON.stringify(x, Object.keys(x)), whose replacer applies at every depth and yields "{}" for a plain Error. The scan reads the tree through support/source-scan.ts rather than growing a sixth private enumerator: sourceFiles walks, and stripComments blanks a comment where it stands, which is what lets the matching work in byte offsets at all — these shapes outrun any line join, a toEqual literal and an if block most of all. logicalLines supplies the quoted expression in the message. It lands green, on an allow-list of the 69 offences standing today — twelve rows for the eleven known findings, the rest newly discovered and marked as such — each with its finding id and the expression it names. One ground covers every entry: it predates the guard and is fix-cycle work. The list is checked both ways, so a fixed offence must leave it with its fix. Shape (b) is deliberately the strict subset. `expect(row?.x).not.toBeNull()` is the same family and the repo holds about 130 of them; this takes only the sites where the short-circuit value IS the asserted value, because a guard with a 130-line allow-list is a wall rather than a guard. Each positive control plants its shape in a fixture tree on disk and runs the whole scan over it, and a negative control plants the fixed counterpart of all six beside them. No production source is touched. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/assertion-shape.test.ts | 1148 ++++++++++++++++++++++++++ 1 file changed, 1148 insertions(+) create mode 100644 server/tests/assertion-shape.test.ts diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts new file mode 100644 index 000000000..92de055c1 --- /dev/null +++ b/server/tests/assertion-shape.test.ts @@ -0,0 +1,1148 @@ +// server/tests/assertion-shape.test.ts +/** + * ASSERTIONS THAT CANNOT FAIL ON THEIR OWN SUBJECT, read off the source rather than run. + * + * Two review rounds turned up some two dozen assertions that pass whatever the code under test + * does. Nothing in this repo could have caught them: `server/tests` is outside `tsc`, the lint set + * carries no floating-assertion rule, and the red-green protocol proves that ONE assertion can + * fail, never its siblings. So a test can be green because it asserts nothing, and stay green + * forever, and no gate in the repo has an opinion about it. + * + * Six of those families have a SYNTACTIC shape — visible in the text, with nothing executed: + * + * (a) a `.rejects` / `.resolves` neither awaited nor returned. The matcher runs on a promise + * nobody waits for; the test ends first and the rejection lands in no assertion. + * (b) a `?.` in an `expect(…)` subject under a matcher that `undefined` satisfies + * (`toBeUndefined`, `toBeFalsy`, `not.toBeDefined`, `toBe(undefined)`). If the subject is + * null the chain short-circuits to exactly the value being asserted, so the assertion holds + * whether or not the subject ever existed. + * (c) a `toEqual` whose EXPECTED literal holds an `undefined` value. Measured on bun 1.4.2: + * `expect({}).toEqual({ a: undefined })` passes — an expected-`undefined` key asserts + * nothing, so those keys are decoration. + * (d) a test every one of whose assertions is lexically inside an `if`, with no `throw` outside + * the branches. When no branch is taken the test asserts nothing and still passes. + * (e) a module-level `const`/`let` written by a stub and never read anywhere — a value recorded + * so carefully that nothing ever looks at it. + * (f) `JSON.stringify(x, Object.keys(x))`. The replacer array applies at EVERY depth, and a + * plain `Error` has no own enumerable keys, so the whole thing is `"{}"` and any + * `.not.toContain(secret)` over it passes on an empty string. + * + * WHY (b) IS THE STRICT SUBSET. `expect(row?.x).not.toBeNull()` is the same family — it passes + * when `row` is undefined — and the repo holds about 130 of them. This guard takes only the + * sites where the short-circuit value IS the asserted value, which is the form that can never + * fail rather than the form that is merely weak. The wider family is noted for the audit, not + * policed here, because a guard whose allow-list is 130 lines long is a wall and not a guard. + * + * WHY IT LANDS WITH AN ALLOW-LIST. Fixing the offenders here would hide them from the fix cycle + * that owns them, and a guard that fails on arrival gets disabled rather than obeyed. So every + * offence this scan finds today is listed below with its finding id, and ONE ground covers every + * entry: it predates the guard and belongs to the fix cycle. The list is checked both ways — an + * unlisted offence fails, and so does a listed one the fix cycle has already cleared, so the list + * shrinks as the fixes land and cannot quietly rot. + * + * HOW IT READS THE SOURCE. Through `support/source-scan.ts`, the shared scanner, and not a sixth + * private copy of the enumerator: `sourceFiles` walks recursively, and `stripComments` blanks a + * comment WHERE IT STANDS, which is what lets this file work in byte offsets at all — every + * position in the stripped text is the position in the file, so a reported line is the line a + * reader is sent to. Matching is by offset rather than by line because these shapes are longer + * than lines: a `toEqual` literal or an `if` block runs past any join limit. `logicalLines` + * supplies the quoted expression in the message, where a wrapped expression should read as one. + * + * The positive controls below plant each shape in a fixture tree on disk and run the WHOLE scan + * over it — walk, strip, match — rather than handing a string to a matcher. A control that only + * feeds the matcher proves the matcher; the enumerator is where the other guards in this repo + * went blind. + */ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + logicalLines, + sourceFiles, + stripComments, +} from "./support/source-scan"; + +/** Which of the six shapes an offence is. */ +export type Shape = "a" | "b" | "c" | "d" | "e" | "f"; + +/** One offence: where it is, which shape, and the expression as the file reads it. */ +export type Offence = { + file: string; + line: number; + shape: Shape; + text: string; +}; + +function blankLiterals(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (c === '"' || c === "'" || c === "`") { + out += c; + let j = i + 1; + while (j < text.length) { + if (text[j] === "\\") { + out += " "; + j += 2; + continue; + } + if (text[j] === c) break; + if (c !== "`" && text[j] === "\n") break; + // A `${…}` hole is CODE, and a value read only there is still read. + if (c === "`" && text[j] === "$" && text[j + 1] === "{") { + let depth = 0; + const from = j; + for (; j < text.length; j++) { + if (text[j] === "{") depth++; + else if (text[j] === "}") { + depth--; + if (depth === 0) { + j++; + break; + } + } + } + out += text.slice(from, j); + continue; + } + out += text[j] === "\n" ? "\n" : " "; + j++; + } + if (j < text.length && text[j] === c) { + out += c; + j++; + } + i = j; + continue; + } + out += c; + i++; + } + return out; +} + +const OPEN: Record = { "(": ")", "[": "]", "{": "}" }; +function closerOf(code: string, open: number): number { + const stack: string[] = []; + for (let i = open; i < code.length; i++) { + const c = code[i]; + if (OPEN[c]) stack.push(OPEN[c]); + else if (c === ")" || c === "]" || c === "}") { + if (stack.pop() !== c) return -1; + if (stack.length === 0) return i; + } + } + return -1; +} + +function lineOf(code: string, index: number): number { + let n = 1; + for (let i = 0; i < index && i < code.length; i++) if (code[i] === "\n") n++; + return n; +} + +function statementStart(code: string, index: number): number { + let depth = 0; + for (let i = index - 1; i >= 0; i--) { + const c = code[i]; + if (c === ")" || c === "]") depth++; + else if (c === "(" || c === "[") { + if (depth > 0) depth--; + } else if (depth === 0 && (c === ";" || c === "{" || c === "}")) + return i + 1; + } + return 0; +} + +function statementEnd(code: string, index: number): number { + let depth = 0; + for (let i = index; i < code.length; i++) { + const c = code[i]; + if (c === "(" || c === "[" || c === "{") depth++; + else if (c === ")" || c === "]" || c === "}") { + if (depth > 0) depth--; + else return i; + } else if (depth === 0 && c === ";") return i; + } + return code.length; +} + +function topLevelArgs(inner: string): string[] { + const args: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < inner.length; i++) { + const c = inner[i]; + if (OPEN[c]) depth++; + else if (c === ")" || c === "]" || c === "}") depth--; + else if (c === "," && depth === 0) { + args.push(inner.slice(start, i)); + start = i + 1; + } + } + args.push(inner.slice(start)); + return args.map((a) => a.trim()); +} + +const MUTATORS = [ + "push", + "pop", + "shift", + "unshift", + "splice", + "add", + "set", + "delete", + "clear", + "fill", + "sort", +]; + +/** Matchers that `undefined` satisfies. */ +const UNDEFINED_PASSES = /^(?:toBeUndefined|toBeFalsy|toBeNil|toBeNullish)$/; + +type Range = { start: number; end: number }; + +export function offencesIn(file: string, source: string): Offence[] { + const stripped = stripComments(source); + const code = blankLiterals(stripped); + const found: Offence[] = []; + // The logical line an offence sits in, for the message: a wrapped expression reads as one. + const joined = logicalLines(stripped); + const quote = (line: number) => { + let best = ""; + for (const l of joined) { + if (l.line > line) break; + best = l.text; + } + return best.trim().replace(/\s+/g, " ").slice(0, 110); + }; + const at = (index: number, shape: Shape) => { + const line = lineOf(code, index); + found.push({ file, line, shape, text: quote(line) }); + }; + + const expects: { index: number; open: number; close: number }[] = []; + for (const m of code.matchAll(/\bexpect\s*\(/g)) { + const index = m.index as number; + const open = index + m[0].length - 1; + const close = closerOf(code, open); + if (close > 0) expects.push({ index, open, close }); + } + + // (a) a floating .rejects / .resolves + for (const m of code.matchAll(/\.(?:rejects|resolves)\b/g)) { + const i = m.index as number; + const start = statementStart(code, i); + const head = code.slice(start, i); + const own = head.lastIndexOf("expect"); + if (own < 0) continue; + const prefix = head.slice(0, own); + if (/\b(?:await|return|yield)\b/.test(prefix)) continue; + if (/=>\s*$/.test(prefix)) continue; + at(start + (head.length - head.trimStart().length), "a"); + } + + // (b) `?.` in the subject, under a matcher `undefined` satisfies + for (const e of expects) { + if (!code.slice(e.open + 1, e.close).includes("?.")) continue; + const chain = /^((?:\s*\.\s*[A-Za-z_$][\w$]*)+)\s*\(/.exec( + code.slice(e.close + 1), + ); + if (!chain) continue; + const parts = chain[1] + .split(".") + .map((p) => p.trim()) + .filter(Boolean); + if (parts.includes("rejects") || parts.includes("resolves")) continue; + const matcher = parts[parts.length - 1]; + const negated = parts.includes("not"); + const argOpen = e.close + 1 + chain[0].length - 1; + const argClose = closerOf(code, argOpen); + const arg = argClose > 0 ? code.slice(argOpen + 1, argClose).trim() : ""; + const undefinedIsThePass = negated + ? matcher === "toBeDefined" + : UNDEFINED_PASSES.test(matcher) || arg === "undefined"; + if (undefinedIsThePass) at(e.index, "b"); + } + + // (c) toEqual over an expected literal holding `undefined` + for (const m of code.matchAll(/\btoEqual\s*\(/g)) { + const open = (m.index as number) + m[0].length - 1; + const close = closerOf(code, open); + if (close < 0) continue; + if (/:\s*undefined\s*[,}]/.test(code.slice(open + 1, close))) + at(m.index as number, "c"); + } + + // guarded regions: the statement an `if` controls + const guards: Range[] = []; + for (const m of code.matchAll(/(? e.index > open && e.index < close); + if (inside.length === 0) continue; + const unguarded = (i: number) => + !guards.some((g) => g.start > open && i > g.start && i < g.end); + // An explicit `throw` outside every branch is a failure path of its own. + const throws = [...code.matchAll(/(? + (t.index as number) > open && + (t.index as number) < close && + unguarded(t.index as number), + ); + if (throws) continue; + if ( + inside.every((e) => + guards.some( + (g) => g.start > open && e.index > g.start && e.index < g.end, + ), + ) + ) + at(m.index as number, "d"); + } + + // (e) a module-level recorder nothing asserts. + // + // "Never read by an expect" is widened to "never read at all", which is the same set and one a + // reader can check: a value read somewhere else is at least wired to something, and the shape + // this looks for is the stub that records into a variable no line of the file ever opens. + for (const m of code.matchAll( + /^(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*)?=\s*(?:\[\s*\]|\{\s*\}|false|true|0|null|""|'')\s*;/gm, + )) { + const name = m[1]; + const decl = m.index as number; + let writes = 0; + let reads = 0; + for (const u of code.matchAll(new RegExp(`\\b${name}\\b`, "g"))) { + const i = u.index as number; + if (i === decl + m[0].indexOf(name)) continue; + const prev = code[i - 1] ?? " "; + if (/[\w$]/.test(prev)) continue; + // `.name` is somebody else's property; `...name` is a read of this one. + if (prev === "." && code[i - 2] !== ".") continue; + // Only a value nobody consumes is a write: `x.push(y);` is, `for (… of x.splice(0))` is not. + const before = code.slice(0, i).trimEnd(); + const statementPosition = before === "" || /[;{}]$/.test(before); + let j = i + name.length; + let mutator = false; + for (;;) { + const rest = code.slice(j); + const prop = /^\s*\.\s*([A-Za-z_$][\w$]*)/.exec(rest); + if (prop) { + mutator = MUTATORS.includes(prop[1]); + j += prop[0].length; + continue; + } + const idx = /^\s*\[[^\]]*\]/.exec(rest); + if (idx) { + mutator = false; + j += idx[0].length; + continue; + } + break; + } + const after = code.slice(j); + const assigned = /^\s*=[^=>]/.test(after); + const called = mutator && /^\s*\(/.test(after); + if (statementPosition && (assigned || called)) writes++; + else reads++; + } + if (writes > 0 && reads === 0) at(decl, "e"); + } + + // (f) JSON.stringify(x, Object.keys(x)) + for (const m of code.matchAll(/\bJSON\.stringify\s*\(/g)) { + const open = (m.index as number) + m[0].length - 1; + const close = closerOf(code, open); + if (close < 0) continue; + const args = topLevelArgs(code.slice(open + 1, close)); + if (args.length >= 2 && /^Object\.keys\s*\(/.test(args[1])) + at(m.index as number, "f"); + } + + return found.sort( + (x, y) => x.line - y.line || x.shape.localeCompare(y.shape), + ); +} + +const REPO = join(import.meta.dir, "..", ".."); +const ROOTS = ["server/tests", "app/tests"]; +const EXTENSIONS = [".ts", ".tsx"]; + +/** Every offence under `root`, with each file named relative to `from`. */ +export function scanTree(root: string, from = root): Offence[] { + const found: Offence[] = []; + for (const file of sourceFiles(root, EXTENSIONS)) { + const named = file.startsWith(`${from}/`) + ? file.slice(from.length + 1) + : file; + found.push(...offencesIn(named, readFileSync(file, "utf8"))); + } + return found; +} + +/** + * An offence this guard does NOT fail on. + * + * One ground covers every entry below and no other ground is accepted: the offence predates this + * guard, so it is fix-cycle work rather than a licence. `finding` names the review that found it, + * or marks it as one this scan turned up that the review rounds did not. `why` quotes the + * expression, so an entry can be read without opening the file. + */ +type Allowed = { + file: string; + line: number; + shape: Shape; + finding: string; + why: string; +}; + +const key = (o: { file: string; line: number; shape: Shape }) => + `${o.file}:${o.line}:${o.shape}`; + +/** + * THE STANDING OFFENDERS, as of the commit that added this file. + * + * Three further round-2 findings are NOT here, because they have no syntactic shape and this scan + * cannot see them — they stay with the fix cycle on the review's word alone: + * - `server/tests/composio-adapter.test.ts:8484` — a literal pinned two lines above its own + * assertion, which is a reading of the file rather than a shape in it. + * - `server/tests/arcade-live.test.ts:542` — `need(x ?? 0, …)` cannot throw, because the `??` + * supplies the value whose absence `need` exists to refuse. + * - `server/tests/server-side-tools.integration.test.ts:196-200` — an audit-event assertion + * scoped to no run, which is a question about the query and not about the syntax. + * And one round-2 finding is not reproduced: `plugin-user-credential.integration.test.ts:475` was + * reported as shape (a), but that test's `.rejects` (at :489-491) IS awaited. Its sibling at :361 + * is the real one and is listed below. + */ +const ALLOW: Allowed[] = [ + { + file: "app/tests/auth-client.test.ts", + line: 83, + shape: "a", + finding: "round 2 — auth-client.test.ts, six sites", + why: 'expect(signInWith("okta", refuse)).rejects.toThrow( "That provider is not configured.", );', + }, + { + file: "app/tests/auth-client.test.ts", + line: 97, + shape: "a", + finding: "round 2 — auth-client.test.ts, six sites", + why: 'test("names the provider when the client says nothing", async () => { const refuse = async () => ({ error: {} ', + }, + { + file: "app/tests/auth-client.test.ts", + line: 101, + shape: "a", + finding: "round 2 — auth-client.test.ts, six sites", + why: 'test("resolves quietly when the redirect is under way", async () => { expect( signInWith("google", async () =>', + }, + { + file: "app/tests/auth-client.test.ts", + line: 162, + shape: "a", + finding: "round 2 — auth-client.test.ts, six sites", + why: 'expect( signInWithEmailDomain("someone@company.com", refuse), ).rejects.toThrow("That domain is registered to ', + }, + { + file: "app/tests/auth-client.test.ts", + line: 170, + shape: "a", + finding: "round 2 — auth-client.test.ts, six sites", + why: 'test("says no provider is registered when the client says nothing", async () => { const refuse = async () => (', + }, + { + file: "app/tests/auth-client.test.ts", + line: 176, + shape: "a", + finding: "round 2 — auth-client.test.ts, six sites", + why: 'test("resolves quietly when the redirect is under way", async () => { expect( signInWithEmailDomain("someone@c', + }, + { + file: "server/tests/audit-initiator.integration.test.ts", + line: 291, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: "expect(reattribute()).rejects.toThrow();", + }, + { + file: "server/tests/component-store.integration.test.ts", + line: 149, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'test("withholding a component that does not exist is an error, not a silent row", async () => { expect( store.', + }, + { + file: "server/tests/component-store.integration.test.ts", + line: 372, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'test("revoking from a component that does not exist is an error, not a silent no-op", async () => { expect(sto', + }, + { + file: "server/tests/component-store.integration.test.ts", + line: 404, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'test("a function this build does not ship cannot be run", async () => { expect(store.callFunction(`nothing_${s', + }, + { + file: "server/tests/computer-client.test.ts", + line: 263, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'test("is its own condition, not an unavailable computer", async () => { expect( timingOut().click({ ref: "e5",', + }, + { + file: "server/tests/computer-client.test.ts", + line: 305, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'test("is a person holding the wheel when the computer says so", async () => { expect( refusing({ error: "A per', + }, + { + file: "server/tests/computer-client.test.ts", + line: 316, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'test("is a stale snapshot when it does not", async () => { expect( refusing({ error: "Snapshot 3 is not the cu', + }, + { + file: "server/tests/computer-client.test.ts", + line: 334, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'expect( refusing(body).click({ ref: "e1", snapshotId: 1 }), ).rejects.toBeInstanceOf(StaleSnapshotError);', + }, + { + file: "server/tests/computer-client.test.ts", + line: 342, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'test("a 409 with no body at all is still a refusal, not a crash", async () => { const bodyless = clientWith(()', + }, + { + file: "server/tests/computer-supervisor.test.ts", + line: 64, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'expect(client.locate("sales")).rejects.toThrow(SupervisorError);', + }, + { + file: "server/tests/computer-supervisor.test.ts", + line: 74, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'expect(client.locate("bad id")).rejects.toThrow( "A bot id may contain only letters.", );', + }, + { + file: "server/tests/computer-supervisor.test.ts", + line: 88, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'expect(client.locate("sales")).rejects.toThrow(/could not be reached/);', + }, + { + file: "server/tests/people-last-sign-in.integration.test.ts", + line: 127, + shape: "a", + finding: "NEW (round 2 did not find this)", + why: 'expect( stampSignIn(broken, "anybody", SIGNED_IN_AT), ).resolves.toBeUndefined();', + }, + { + file: "server/tests/plugin-user-credential.integration.test.ts", + line: 364, + shape: "a", + finding: "round 2 — plugin-user-credential.integration.test.ts:361", + why: 'describe("a person who has not connected", () => { test("is refused, and told to connect rather than told it b', + }, + { + file: "app/tests/bot-setup.test.tsx", + line: 178, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(updates[0]?.endpoint).toBeUndefined();", + }, + { + file: "app/tests/voice-archive.test.ts", + line: 98, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(records[0]?.saveState).toBeUndefined();", + }, + { + file: "app/tests/voice-archive.test.ts", + line: 149, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: 'expect((await loadVoiceArchive("channel-1"))[0]?.saveState).toBeUndefined();', + }, + { + file: "server/tests/agent-endpoint.test.ts", + line: 483, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(forwarded.forwardedProps?.openbotRun).toBeUndefined();", + }, + { + file: "server/tests/agent-escalation.test.ts", + line: 78, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(written[0]?.initiator).toBe(undefined);", + }, + { + file: "server/tests/agent-handoff.test.ts", + line: 368, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(plain.events[0]?.initiator).toBe(undefined);", + }, + { + file: "server/tests/approvals-interactive.integration.test.ts", + line: 229, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(continued[0]?.error).toBeUndefined();", + }, + { + file: "server/tests/approvals-policy.test.ts", + line: 715, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(continued[0]?.error).toBeUndefined();", + }, + { + file: "server/tests/arcade-adapter-errors.test.ts", + line: 63, + shape: "b", + finding: "round 2 — arcade-adapter-errors.test.ts:62-66", + why: 'test("a connection failure keeps status undefined", () => { expect( normalizeArcadeError(new APIConnectionErro', + }, + { + file: "server/tests/arcade-sibling-collateral.test.ts", + line: 392, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(row(t.gmail)?.payload.endedWith).toBeUndefined();", + }, + { + file: "server/tests/arcade-sibling-collateral.test.ts", + line: 396, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(row(t.slack)?.payload.endedWith).toBeUndefined();", + }, + { + file: "server/tests/audit-export.test.ts", + line: 181, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(seen[0]?.cursor).toBeUndefined();", + }, + { + file: "server/tests/computer-gateway.test.ts", + line: 381, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(rows[0]?.actorUserId).toBeUndefined();", + }, + { + file: "server/tests/computer-gateway.test.ts", + line: 443, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(rows[0]?.payload.element).toBeUndefined();", + }, + { + file: "server/tests/computer-gateway.test.ts", + line: 646, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(rows.at(-1)?.payload.element).toBeUndefined();", + }, + { + file: "server/tests/computer-provider.test.ts", + line: 356, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: 'expect(await provider.sessionOf?.("sales")).toBeUndefined();', + }, + { + file: "server/tests/computer-provider.test.ts", + line: 365, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: 'expect(await provider.sessionOf?.("sales")).toBeUndefined();', + }, + { + file: "server/tests/computer-sandbox.test.ts", + line: 78, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: 'expect( await providerWith(suspended).sessionOf?.("knowledge"), ).toBeUndefined();', + }, + { + file: "server/tests/headless-components.integration.test.ts", + line: 341, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(bar?.hidden).toBeUndefined();", + }, + { + file: "server/tests/headless-components.test.ts", + line: 192, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(bar?.hidden).toBeUndefined();", + }, + { + file: "server/tests/identity-retire.integration.test.ts", + line: 124, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(audit.rows[0]?.actorUserId).toBeUndefined();", + }, + { + file: "server/tests/identity-retire.integration.test.ts", + line: 247, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(audit.rows[0]?.actorUserId).toBeUndefined();", + }, + { + file: "server/tests/mcp-listing.test.ts", + line: 168, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(fetch?.effect).toBeUndefined();", + }, + { + file: "server/tests/mcp-listing.test.ts", + line: 179, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(search?.effect).toBeUndefined();", + }, + { + file: "server/tests/mcp-listing.test.ts", + line: 180, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(search?.destructive).toBeUndefined();", + }, + { + file: "server/tests/picked-harness-auth.test.ts", + line: 290, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: 'test("a plan\'s installed picked harness authenticates without advertising a bundled Bot", async () => { const ', + }, + { + file: "server/tests/picked-harness-auth.test.ts", + line: 355, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(result.config.managedAgent?.endpoint).toBeUndefined();", + }, + { + file: "server/tests/plugin-catalogue.test.ts", + line: 210, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(entry?.transport).toBeUndefined();", + }, + { + file: "server/tests/shared-computer-run.integration.test.ts", + line: 264, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: 'expect((await snapshots.load("bot-1"))?.session).toBeUndefined();', + }, + { + file: "server/tests/tenant-package.test.ts", + line: 358, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(tenantPackage.agents[1]?.avatarSeed).toBeUndefined();", + }, + { + file: "server/tests/tool-selection.integration.test.ts", + line: 834, + shape: "b", + finding: "NEW (round 2 did not find this)", + why: "expect(run?.forwardedProps?.openbotRun).toBeUndefined();", + }, + { + file: "app/tests/user-preferences.test.tsx", + line: 75, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'test("loads preferences from the account and renders the saved emphasis", async () => { const { view } = setup', + }, + { + file: "server/tests/agent-handoff.test.ts", + line: 622, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: ').toEqual([ { eventType: "agent.handoff_offered", reason: undefined }, { eventType: "agent.handoff_refused", r', + }, + { + file: "server/tests/agent-test-connection-headers.test.ts", + line: 12, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'describe("parseConnectionHeaders", () => { test("absent headers stay absent", () => { expect(parseConnectionHe', + }, + { + file: "server/tests/arcade-adapter-errors.test.ts", + line: 55, + shape: "c", + finding: "round 2 — arcade-adapter-errors.test.ts:50-60", + why: ").toEqual({", + }, + { + file: "server/tests/composio-adapter.test.ts", + line: 5067, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', + }, + { + file: "server/tests/composio-adapter.test.ts", + line: 5115, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', + }, + { + file: "server/tests/computer-gateway.test.ts", + line: 808, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'expect(result).toEqual({ isolation: "per-bot", computers: [ { botId: "bot-proxied",', + }, + { + file: "server/tests/demonstrations.test.ts", + line: 294, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'expect(created).toEqual([ { ownerUserId: "owner", agentId: "bot", instruction: demonstrationRoutineInstruction', + }, + { + file: "server/tests/routine-runner.test.ts", + line: 168, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'expect(recorded.finished).toEqual([ { runId: RUN_ID, status: "succeeded", error: undefined }, ]);', + }, + { + file: "server/tests/routine-runner.test.ts", + line: 271, + shape: "c", + finding: "NEW (round 2 did not find this)", + why: 'expect(recorded.finished).toEqual([ { runId: RUN_ID, status: "succeeded", error: undefined }, ]);', + }, + { + file: "app/tests/connected-accounts-authorized-notice.test.tsx", + line: 101, + shape: "d", + finding: "round 2 — connected-accounts-authorized-notice.test.tsx:101-113", + why: 'test.each([ "no-attempt", "toString", "constructor", "__proto__",', + }, + { + file: "app/tests/connected-account-grants.test.tsx", + line: 124, + shape: "e", + finding: "NEW (round 2 did not find this)", + why: "const siblingReads: string[] = [];", + }, + { + file: "server/tests/arcade-attempt-store.integration.test.ts", + line: 81, + shape: "e", + finding: "NEW (round 2 did not find this)", + why: "const actors: string[] = [];", + }, + { + file: "server/tests/arcade-disconnect-cascade.test.ts", + line: 36, + shape: "e", + finding: "round 2 — arcade-disconnect-cascade.test.ts:36,60", + why: "const revoked: string[] = [];", + }, + { + file: "server/tests/arcade-grants-route.test.ts", + line: 139, + shape: "e", + finding: "NEW (round 2 did not find this)", + why: "const toolkits: string[] = [];", + }, + { + file: "server/tests/arcade-grants-route.test.ts", + line: 140, + shape: "e", + finding: "NEW (round 2 did not find this)", + why: "const viewers: string[] = [];", + }, + { + file: "server/tests/enterprise-controls.integration.test.ts", + line: 58, + shape: "e", + finding: "NEW (round 2 did not find this)", + why: "const stopped: string[] = []; — pushed to at :65, cleared at :718, read nowhere; the `.stopped` at :704 and :720 is the response's own field", + }, + { + file: "server/tests/arcade-vendor-error-engine.test.ts", + line: 175, + shape: "f", + finding: "round 2 — arcade-vendor-error-engine.test.ts:174", + why: 'expect( JSON.stringify(thrown, Object.keys(thrown as object)), ).not.toContain("SECRET-BEARER");', + }, +]; + +/** One offence per line, the way a reader wants to see it. */ +const report = ( + offences: { shape: Shape; file: string; line: number; text: string }[], +) => offences.map((o) => `(${o.shape}) ${o.file}:${o.line} ${o.text}`).sort(); + +/* THE GUARD ITSELF. */ + +for (const root of ROOTS) { + test(`${root} grows no assertion shape the fix cycle does not already own`, () => { + const allowed = new Set(ALLOW.map(key)); + const scanned = scanTree(join(REPO, root), REPO); + expect(report(scanned.filter((o) => !allowed.has(key(o))))).toEqual([]); + // And the walk reached the tree at all: a scan of nothing reports nothing. + expect(sourceFiles(join(REPO, root), EXTENSIONS).length).toBeGreaterThan( + 20, + ); + }); +} + +test("the allow-list holds no offence the fix cycle has already cleared", () => { + const found = new Set( + ROOTS.flatMap((root) => scanTree(join(REPO, root), REPO)).map(key), + ); + const stale = ALLOW.filter((entry) => !found.has(key(entry))); + // A fixed offence must leave the list with its fix, or the list stops meaning anything. + expect(stale.map((entry) => `${key(entry)} ${entry.finding}`)).toEqual([]); +}); + +/* POSITIVE CONTROLS. + * + * Each plants one shape in a file on disk, at a nesting and an extension the real trees have, and + * runs the WHOLE scan over the fixture root. A control that hands a string to the matcher proves + * the matcher; this one has to survive the walk and the stripper to be seen at all. */ + +let FIXTURES = ""; + +/** Write `text` at `rel` under the fixture root, creating the directories it needs. */ +function fixture(rel: string, text: string): void { + const full = join(FIXTURES, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, text, "utf8"); +} + +/** The one offence of a shape in the fixture tree, or a failure naming what the scan did see. */ +function planted(shape: Shape): Offence { + const hits = scanTree(FIXTURES).filter((o) => o.shape === shape); + expect(report(hits)).toHaveLength(1); + return hits[0]; +} + +beforeAll(() => { + FIXTURES = mkdtempSync(join(tmpdir(), "assertion-shape-")); + + fixture( + "a/floating.ts", + `test("the refusal is refused", () => { + expect(refuse()).rejects.toThrow(Boom); +}); +`, + ); + + fixture( + "nested/deep/b/chain.ts", + `test("a connection failure keeps status undefined", () => { + expect(normalize(failure)?.status).toBeUndefined(); +}); +`, + ); + + fixture( + "c/equal.tsx", + `test("an error with no body leaves name undefined", () => { + expect(read()).toEqual({ + status: 500, + name: undefined, + }); +}); +`, + ); + + fixture( + "d/guarded.tsx", + `test("a sentence or nothing, never an object", () => { + const notice = noticeFor(value); + if (notice !== null) expect(typeof notice).toBe("string"); +}); +`, + ); + + fixture( + "e/recorder.ts", + `const revoked: string[] = []; + +const broker = { + revoke: async (toolkit: string) => { + revoked.push(toolkit); + return true; + }, +}; + +test("a retired identity's apps are all revoked", async () => { + await disconnect(broker); + expect(true).toBe(true); +}); +`, + ); + + fixture( + "deep/down/f/stringify.ts", + `test("nothing the vendor wrote survives the log", () => { + expect(JSON.stringify(thrown, Object.keys(thrown))).not.toContain("SECRET"); +}); +`, + ); + + // The same six, written the way the fix cycle will write them. + fixture( + "clean/fixed.ts", + `const seen: string[] = []; + +const broker = { + revoke: async (toolkit: string) => { + seen.push(toolkit); + return true; + }, +}; + +test("the refusal is refused", async () => { + await expect(refuse()).rejects.toThrow(Boom); +}); + +test("a connection failure keeps status undefined", () => { + const normalized = need(normalize(failure), "nothing normalized"); + expect(normalized.status).toBeUndefined(); +}); + +test("an error with no body leaves name undefined", () => { + expect(read()).toEqual({ status: 500 }); + expect(read()).not.toHaveProperty("name"); +}); + +test("a sentence or nothing, never an object", () => { + const notice = noticeFor(value); + expect(notice === null || typeof notice === "string").toBe(true); +}); + +test("a retired identity's apps are all revoked", async () => { + await disconnect(broker); + expect(seen).toEqual(["gmail"]); +}); + +test("nothing the vendor wrote survives the log", () => { + expect(JSON.stringify(thrown, ["message", "name"])).not.toContain("SECRET"); +}); +`, + ); +}); + +afterAll(() => { + if (FIXTURES) rmSync(FIXTURES, { recursive: true, force: true }); +}); + +test("(a) a discarded .rejects is seen, two directories down and all", () => { + const offence = planted("a"); + expect(`${offence.file}:${offence.line}`).toBe("a/floating.ts:2"); + expect(offence.text).toContain("expect(refuse()).rejects.toThrow(Boom);"); +}); + +test("(b) a `?.` subject under a matcher undefined satisfies is seen", () => { + const offence = planted("b"); + expect(`${offence.file}:${offence.line}`).toBe("nested/deep/b/chain.ts:2"); + expect(offence.text).toContain( + "expect(normalize(failure)?.status).toBeUndefined()", + ); +}); + +test("(c) an expected-undefined key in a toEqual literal is seen", () => { + // Four physical lines, which is the reason this scan works in offsets and not in lines. + const offence = planted("c"); + expect(`${offence.file}:${offence.line}`).toBe("c/equal.tsx:2"); + expect(offence.text).toContain("name: undefi"); +}); + +test("(d) a test whose every assertion sits inside an `if` is seen", () => { + const offence = planted("d"); + expect(`${offence.file}:${offence.line}`).toBe("d/guarded.tsx:1"); + expect(offence.text).toContain("const notice = noticeFor(value);"); +}); + +test("(e) a recorder nothing ever reads is seen", () => { + const offence = planted("e"); + expect(`${offence.file}:${offence.line}`).toBe("e/recorder.ts:1"); + expect(offence.text).toContain("const revoked: string[] = [];"); +}); + +test("(f) JSON.stringify(x, Object.keys(x)) is seen", () => { + const offence = planted("f"); + expect(`${offence.file}:${offence.line}`).toBe("deep/down/f/stringify.ts:2"); + expect(offence.text).toContain("JSON.stringify(thrown, Object.keys(thrown))"); +}); + +/* NEGATIVE CONTROL. The same six written correctly, in a file the walk reaches, so a detector + * that fires on everything fails here rather than passing six tests and one audit. */ + +test("the fixed counterparts of all six are reported as nothing", () => { + expect( + report(scanTree(FIXTURES).filter((o) => o.file.startsWith("clean/"))), + ).toEqual([]); + // And the file was genuinely read, rather than missed by the walk. + expect( + sourceFiles(FIXTURES, EXTENSIONS).map((f) => f.slice(FIXTURES.length + 1)), + ).toContain("clean/fixed.ts"); +}); From 1d80c6ad1ae0513fc9f6cd65f5ca510b5073ab44 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 02:51:06 +0200 Subject: [PATCH 163/232] style(arcade): biome format the revoke-invariant harness Three hunks the formatter rewrites, one of them the orchestrator's own port-refusal edit. Behaviour unchanged: 3 pass / 4 fail, same four shapes red, before and after. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-revoke-invariant.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/server/tests/arcade-revoke-invariant.test.ts b/server/tests/arcade-revoke-invariant.test.ts index e3738afdc..e3e81d3c6 100644 --- a/server/tests/arcade-revoke-invariant.test.ts +++ b/server/tests/arcade-revoke-invariant.test.ts @@ -192,7 +192,11 @@ function startEngine(shape: Shape): Engine { return Response.json({ items: [ { id: "google-openbot", provider_id: "google", type: "oauth2" }, - { id: "arcade-microsoft", provider_id: "microsoft", type: "oauth2" }, + { + id: "arcade-microsoft", + provider_id: "microsoft", + type: "oauth2", + }, ], offset: 0, limit, @@ -206,7 +210,9 @@ function startEngine(shape: Shape): Engine { const providers = providersFor(shape); const page = offset === 0 - ? Array.from({ length: 4 }, (_, n) => toolRow(toolkit, n, providers)) + ? Array.from({ length: 4 }, (_, n) => + toolRow(toolkit, n, providers), + ) : []; return Response.json({ items: page, @@ -224,7 +230,8 @@ function startEngine(shape: Shape): Engine { // never bound a port would otherwise send every request of this file to a nonsense address and // the invariant below would "hold" for the wrong reason. const { port } = server; - if (port === undefined) throw new Error("the stub vendor did not bind a port"); + if (port === undefined) + throw new Error("the stub vendor did not bind a port"); return { port, requests, stop: () => server.stop(true) }; } From 598f9a319ed667e37ec8cbc3cca6c6b00343a05c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:27:56 +0200 Subject: [PATCH 164/232] test(arcade): make the disconnect guards see the vendor side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Composio case asserted the row gone and the trail written while never asserting that a DELETE was spent at the vendor, so a disconnect that deleted locally and revoked nothing passed it. Same gap on the false-passthrough case, where the body's `false` could have been a route default rather than the vendor's own answer. Both now count the asks reaching the broker stub. "a partial vendor failure" made exactly one revoke over one connection and exercised nothing partial. Split in two: the throw case now holds three apps on one auth provider, so the single ask covers two siblings and the throw has to leave all three; and a new case runs a wave over two of an identity's connections with the stub answering per toolkit — one revoke succeeds, the next throws — asserting both asks reached the vendor, the succeeded one ended, and the failed one left its own row and its sibling's standing with nothing audited. Each added assertion was watched failing against a planted defect. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-disconnect-route.test.ts | 62 +++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/server/tests/arcade-disconnect-route.test.ts b/server/tests/arcade-disconnect-route.test.ts index 91eca9bfd..f587bd35d 100644 --- a/server/tests/arcade-disconnect-route.test.ts +++ b/server/tests/arcade-disconnect-route.test.ts @@ -30,7 +30,13 @@ let counter = 0; type Provider = "arcade" | "composio"; let revokes: string[] = []; -let revokeAnswer: boolean | Error = true; +/** + * What the vendor stub answers. A function answers PER TOOLKIT, which is the only way a case can + * have one connection's revoke succeed while another's fails: the store makes exactly one ask per + * disconnect, so a partial failure is a wave of disconnects and not a single call that half-ran. + */ +type RevokeAnswer = boolean | Error | ((toolkit: string) => boolean | Error); +let revokeAnswer: RevokeAnswer = true; const providerOf = (provider: Provider) => (toolkit: string) => provider === "composio" @@ -55,8 +61,12 @@ function brokerFor(provider: Provider): ConnectedAppBroker { isConnected: async () => true, revoke: async (request) => { revokes.push(request.toolkit); - if (revokeAnswer instanceof Error) throw revokeAnswer; - return revokeAnswer; + const answer = + typeof revokeAnswer === "function" + ? revokeAnswer(request.toolkit) + : revokeAnswer; + if (answer instanceof Error) throw answer; + return answer; }, connectionFields: async () => [], connectWithFields: async () => ({ accountId: "ca_1" }), @@ -234,21 +244,53 @@ describe("the disconnect", () => { ); }); - test("a partial vendor failure leaves EVERY row standing and audits nothing", async () => { + test("a revoke that throws leaves EVERY row of the identity standing and audits nothing", async () => { const n = run(); - for (const t of [n.gmail, n.calendar]) await app(n, t); + // Three apps on ONE auth provider, so the single ask below covers two siblings as well as the + // app pressed: the throw must leave all three, not only the one named. + for (const t of [n.gmail, n.calendar, n.docs]) await app(n, t); await hold(n, n.gmail, n.person); await hold(n, n.calendar, n.person); + await hold(n, n.docs, n.person); revokeAnswer = new Error("revoke failed halfway"); const response = await disconnect(n, n.gmail); expect(response.status).toBeGreaterThanOrEqual(500); expect(revokes).toEqual([n.gmail]); - expect(await rowsFor([n.gmail, n.calendar])).toEqual( + expect(await rowsFor([n.gmail, n.calendar, n.docs])).toEqual( + [n.gmail, n.calendar, n.docs].sort(), + ); + expect(await trailFor([n.gmail, n.calendar, n.docs])).toEqual([]); + }); + + test("a partial failure across an identity's connections ends the asked one and leaves the failed one whole", async () => { + const n = run(); + // TWO connections for one identity: Slack is its own auth provider, Gmail and Calendar share + // google. The store asks the vendor ONCE per disconnect, so "partial" is this wave — one ask + // answered, the next thrown on — and the point is that the succeeded half is not rolled back + // and the failed half is not half-applied. + for (const t of [n.gmail, n.calendar, n.slack]) await app(n, t); + await hold(n, n.gmail, n.person); + await hold(n, n.calendar, n.person); + await hold(n, n.slack, n.person); + revokeAnswer = (toolkit) => + toolkit === n.slack ? true : new Error("revoke failed halfway"); + + const ok = await disconnect(n, n.slack); + const failed = await disconnect(n, n.gmail); + + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ vendorRevocationRequested: true }); + expect(failed.status).toBeGreaterThanOrEqual(500); + // BOTH asks reached the vendor, in that order: the failure is the vendor's answer and not a + // disconnect this deployment declined to make. + expect(revokes).toEqual([n.slack, n.gmail]); + // The succeeded one is gone; the failed one took NEITHER its own row nor its sibling's. + expect(await rowsFor([n.gmail, n.calendar, n.slack])).toEqual( [n.gmail, n.calendar].sort(), ); - expect(await trailFor([n.gmail, n.calendar])).toEqual([]); + expect(await trailFor([n.gmail, n.calendar, n.slack])).toEqual([n.slack]); }); test("answers what the vendor was asked, passing a false through unrewritten", async () => { @@ -260,6 +302,9 @@ describe("the disconnect", () => { const response = await disconnect(n, n.gmail); expect(await response.json()).toEqual({ vendorRevocationRequested: false }); + // The false is the VENDOR's: one DELETE was spent asking, not a default returned by a route + // that deleted the row and never asked. + expect(revokes).toEqual([n.gmail]); expect(await rowsFor([n.gmail])).toEqual([]); }); }); @@ -277,6 +322,9 @@ describe("Composio, where an app is its own auth provider", () => { const response = await disconnect(n, n.gmail); expect(response.status).toBe(200); + // ONE DELETE spent at the vendor, for the app pressed and no other: the local row going is + // half the disconnect, and a row deleted with no ask leaves a live grant nothing here reaches. + expect(revokes).toEqual([n.gmail]); expect(await rowsFor([n.gmail, n.calendar])).toEqual([n.calendar]); expect(await trailFor([n.gmail, n.calendar])).toEqual([n.gmail]); }); From d942c52385f49ce0e0a11a49c964ae3a2a4533f1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:31:30 +0200 Subject: [PATCH 165/232] fix(arcade): an unattributable toolkit refuses instead of answering "nothing to withdraw" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connectionsFor` resolved a toolkit's auth provider through `authProviderOf` and answered NO ROWS whenever that came back `null` — which it does when the toolkit's actions name ZERO provider ids or TWO OR MORE of them. That is not a listing saying somebody holds nothing; it is a listing this deployment could not read an attribution out of, and answering it cost two live defects: 1. `revoke` answered `false` — "there was nothing to withdraw" — WITHOUT SPENDING A SINGLE DELETE, while `store.ts`'s disconnect path (`:8131`, `:4226`) deletes the local `brokered_connections` row on that answer regardless. The outcome is the one `broker.ts:712-717` forbids outright: a LIVE grant at the vendor that no row this deployment holds any longer names, and nothing left to name the app on a second press of Disconnect. 2. `isConnected` answered false and `accountName` null for a genuinely connected person, so Connect minted a SECOND authorization beside the live one. An unresolvable provider is now a `BrokerRefusalError` out of `connectionsFor`, beside the malformed/truncated/never-ending listing refusals it already raised, and all three lifecycle members refuse together. The refusal reaches the Disconnect path BEFORE any local row is deleted. The sentence names which case happened and, where several were named, lists them sorted, so it does not change with the order a page happened to arrive in. `BrokerRefusalError` and not a bare `Error` because the sentence is this deployment's own and safe to show: the route reads it through `brokerSentence` instead of saying "the vendor said nothing about why, check the key" about a sentence written here. There is NO fallback to the toolkit name, as there never was: it would restore the instance/type defect of 28b13e4a under a different spelling. `authProviderOf`'s reading of the wire is split into `authProvidersNamedBy`, which answers the DISTINCT ids the listing named. The verdict and the evidence are two things: `null` is a verdict with the evidence thrown away, and a refusal has to say whether it read none or several. One reading of the wire, as before. Call sites of everything whose signature or behaviour changed: - `authProvidersNamedBy` (new, module-private): called by `authProviderOf` and by `connectionsFor`. Nothing else. - `authProviderOf` (module-private): signature and `string | null` contract UNCHANGED; it now folds `authProvidersNamedBy`'s set. One caller, the seam member `broker.authProviderOf`, which keeps answering `null` — its consumer is `store.ts:3054` `resolveSiblings`, which counts an unresolved app as `unanswered` and ends nothing for it. That question spends no delete, so fail-closed is a real answer there; the destructive question is the one that now refuses. - `connectionsFor` (module-private): signature unchanged, now REJECTS on an unattributable toolkit. Three callers, all in this file — `accountName`, `isConnected`, `revoke`. - `broker.revoke` on Arcade: `server/src/plugins/store.ts:4227` (`removeServer`), `:8131` (`disconnectBrokeredAccount`), `:8508` (offboarding), and `server/src/plugins/routes.ts:3341` (retire-with-collateral, which catches and reads not-withdrawn). Every one of them takes the throw before deleting a local row, which is the recovery `broker.ts` asks for. - `broker.isConnected` on Arcade: `server/src/plugins/store.ts:7049` (`confirmBrokeredConnection`) — the throw now reaches the caller instead of a `false` that would delete the row. - `broker.accountName` on Arcade: `server/src/plugins/store.ts:6014`, which already `.catch(() => null)`s, so the deployment-account panel is unchanged. Tests: `arcade-revoke-invariant.test.ts` is UNTOUCHED — its two provider-id cases ("zero provider ids", "two or more provider ids") go green off the production change. `arcade-adapter-connections.test.ts`'s unresolved-toolkit test is rewritten as three: no-provider, two-providers, and one pinning that the refusal is an AUTHORED one `brokerSentence` reads. `arcade-pagination-engine.test.ts`'s split-provider case keeps its mechanism (the action listing is paged, so the second page's `microsoft` is seen) and asserts the refusal rather than the old no-rows. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 87 +++++++++++++++---- server/src/plugins/routes.ts | 8 +- .../tests/arcade-adapter-connections.test.ts | 82 +++++++++++++++-- server/tests/arcade-pagination-engine.test.ts | 23 +++-- 4 files changed, 168 insertions(+), 32 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index e3a98786a..1f9009612 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -601,20 +601,46 @@ async function authProviderInstances( * out at this level would answer both at once, and the one it answered wrongly would be the * destructive one. * - * AN UNRESOLVED TOOLKIT (`null`) ANSWERS AS NO ROWS and does NOT fall back to the toolkit name: a - * fallback would silently restore the defect under a different spelling, invisibly against every - * fixture in the tree. Callers then answer `isConnected` false, `revoke` false (no delete spent), - * `accountName` null. A listing that could not be read WHOLE is different: `authProviderOf` and - * {@link authProviderInstances} both THROW on one that is malformed, truncated against its own - * reported total, or never-ending, and that propagates, onto the Disconnect path included. + * AN UNRESOLVED TOOLKIT REFUSES, AND DOES NOT FALL BACK TO THE TOOLKIT NAME. The fallback is + * refused for the reason it always was — it would silently restore the instance/type defect under + * a different spelling, invisibly against every fixture in the tree. But ANSWERING NO ROWS IS NOT + * THE OTHER OPTION, and that is this function's own measured defect. A toolkit whose actions name + * ZERO provider ids, or TWO OR MORE of them, is not a toolkit with no connections: it is a listing + * this deployment could not read an attribution out of. Answering `[]` for it made `isConnected` + * false and `accountName` null for somebody genuinely connected — so Connect minted a SECOND + * authorization beside the live one — and made `revoke` answer `false` ("there was nothing to + * withdraw") WITHOUT SPENDING A SINGLE DELETE, while `store.ts`'s disconnect path deletes the + * local `brokered_connections` row on that answer regardless. The outcome is the one + * `broker.ts`'s `revoke` contract forbids outright: a LIVE grant at the vendor that no row this + * deployment holds any longer names, and no way back but guessing which app it belonged to. + * + * So an unattributable toolkit throws a {@link BrokerRefusalError} naming what the listing said, + * exactly as a malformed one does — `authProviderOf` and {@link authProviderInstances} THROW on a + * listing that is malformed, truncated against its own reported total, or never-ending, and this + * joins them. All three lifecycle members refuse together, which is the point: the refusal reaches + * the Disconnect path BEFORE any local row is deleted, so the row is still standing on the second + * press, and the Connect screen says why rather than offering a second grant. + * + * THE SEAM'S `authProviderOf` STILL ANSWERS `null`, and that is not the same decision. Its caller + * is the sibling resolver in `store.ts`, which counts an unresolved app as `unanswered` and ends + * NOTHING for it — fail-closed on a question that spends no delete. Here the question is the + * destructive one. */ async function connectionsFor( client: Arcade, vendorUserId: string, toolkit: string, ): Promise { - const authProvider = await authProviderOf(client, toolkit); - if (authProvider === null) return []; + const named = await authProvidersNamedBy(client, toolkit); + if (named.size !== 1) { + throw new BrokerRefusalError( + named.size === 0 + ? `Arcade's action listing for ${toolkit} names no auth provider at all, so which of this account's connections belong to ${toolkit} could not be read. Nothing was read as connected and nothing was withdrawn — a connection may still be live at the vendor. Give ${toolkit} an auth provider in the Arcade dashboard and try again.` + : `Arcade's action listing for ${toolkit} names ${named.size} different auth providers (${[...named].sort().join(", ")}), so which of this account's connections belong to ${toolkit} could not be read. Nothing was read as connected and nothing was withdrawn — a connection may still be live at the vendor. Settle ${toolkit} on one auth provider in the Arcade dashboard and try again.`, + ); + } + // The one id, taken off the set the refusal above has already narrowed to exactly one. + const [authProvider] = [...named]; const instances = await authProviderInstances(client, authProvider); const rows: ConnectionRow[] = []; for await (const item of pagedItems({ @@ -909,13 +935,19 @@ async function grantsFor( * unanswered; whether they are the same provider is the caller's comparison, and * `connectionSiblings` folds case for the one caller that makes it. * - * `null` IS WHAT UNANSWERED MEANS, AND IT ALREADY MEANT IT. It is not "no provider": the sole - * caller, {@link connectionsFor}, answers NO ROWS for it and never falls back to the toolkit name, - * so `isConnected` reads false, `accountName` null and `revoke` false WITHOUT SPENDING A DELETE. - * That is fail-closed on all three — nothing is deleted and nothing is claimed connected on a - * toolkit nobody can attribute — which is exactly what an ambiguous toolkit deserves, so there is - * no second spelling of "unanswered" to invent. A toolkit with no provider id anywhere lands on the - * same answer for the same reason. + * `null` IS WHAT UNANSWERED MEANS, AND IT IS NOT AN ANSWER ANY CALLER MAY SPEND. It is not "no + * provider", and a toolkit with no provider id anywhere lands on it for the same reason an + * ambiguous one does. What a caller must do with it depends on what its own answer costs: + * + * - {@link connectionsFor} — the connection lifecycle, where the answer spends DELETEs and decides + * whether somebody is shown as connected — REFUSES, and reads {@link authProvidersNamedBy} + * directly so its sentence can say whether the listing named none or several. Answering no rows + * there was the live defect: `revoke` said `false` ("nothing to withdraw") with no delete spent + * while the caller deleted the local row anyway, and `isConnected`/`accountName` sent somebody + * who is already connected round a second authorization. Its comment has that in full. + * - The SEAM member below hands `null` on unchanged, for `store.ts`'s sibling resolver, which + * counts an unresolved app as `unanswered` and ends nothing for it. That question spends + * nothing, so fail-closed is a real answer there and the seam's `Promise` stands. * * `requirements.met` IS NOT READ HERE EITHER, for `grantStateOf`'s reason: this reads a DIFFERENT * field of the same `requirements.authorization` object. @@ -924,6 +956,23 @@ async function authProviderOf( client: Arcade, toolkit: string, ): Promise { + const named = await authProvidersNamedBy(client, toolkit); + return named.size === 1 ? [...named][0] : null; +} + +/** + * The DISTINCT auth provider ids a toolkit's listing names, read to the listing's end. + * + * SEPARATED FROM {@link authProviderOf} SO THAT THE VERDICT AND THE EVIDENCE ARE TWO THINGS. The + * seam's `authProviderOf` answers a provider or `null`, and `null` is a verdict with the evidence + * thrown away — zero ids and nine ids arrive at its caller as the same word. {@link connectionsFor} + * REFUSES on either, and a refusal has to say which one happened and name what it read, so it reads + * the set and `authProviderOf` folds the set to its verdict. There is still ONE reading of the wire. + */ +async function authProvidersNamedBy( + client: Arcade, + toolkit: string, +): Promise> { const named = new Set(); for await (const item of pagedItems({ pageSize: CATALOGUE_PAGE_SIZE, @@ -941,7 +990,7 @@ async function authProviderOf( const provider = textOf(record(authorization)?.provider_id); if (provider) named.add(provider); } - return named.size === 1 ? [...named][0] : null; + return named; } /** @@ -1119,6 +1168,12 @@ export function createArcadeClient(apiKey: string): { // false when there was nothing. Every delete is attempted, then ANY failure throws: a partial // ask must not read as `false` ("nothing there") nor as a clean `true`. // + // AND `false` IS ONLY EVER SAID OFF A LISTING READ WHOLE, which is {@link connectionsFor}'s + // promise rather than this one's: an unreadable, truncated or UNATTRIBUTABLE listing throws out + // of it before this line, so `rows.length === 0` here means the vendor said so. `false` with no + // delete spent is the live defect's exact signature — the caller deletes the local row on it — + // and `arcade-revoke-invariant.test.ts` asserts the outcome rather than any one mechanism. + // // ON EVERY ROW, LIVE OR NOT, WHICH IS WHY THIS DOES NOT ASK {@link connectionIsLive}. A dead row // is still a row at the vendor: skipping it would leave it standing while this answered `false` // ("there was nothing to withdraw"), and a reconnect would then be made beside debris nobody can diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index e702e1a9d..8871afbe4 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3332,10 +3332,10 @@ export function createPluginRoutes( if (retiredApp && collateral) { try { /* - * THE BOOLEAN IS READ, NOT DROPPED. `false` used to mean only "nothing there", but the - * lifecycle trio filters on the RESOLVED auth provider, so it now also means "we could - * not work out what to withdraw". Over a possibly-live grant the safe reading of that - * ambiguity is "we do not know": it takes the not-withdrawn branch, as a throw does. + * THE BOOLEAN IS READ, NOT DROPPED. `false` means "nothing there" — on Arcade a + * toolkit whose auth provider could NOT be resolved now refuses rather than answering + * it, so an unattributable app arrives here as the throw below. Both land on the + * not-withdrawn branch, which is the safe reading over a possibly-live grant. */ withdrawn = await withVendorDeadline(() => composio.broker.revoke({ diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index b82aa4827..979e4df99 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import Arcade from "@arcadeai/arcadejs"; import { useArcadeClient } from "../src/plugins/arcade"; import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import { brokerSentence } from "../src/plugins/broker"; import type { AccountRef } from "../src/plugins/shared-accounts"; /** Prototype stubs BEFORE construction; `new Arcade({ apiKey })` opens no socket. */ @@ -368,9 +369,23 @@ describe("arcade adapter connection lifecycle", () => { expect(deleted).toEqual(["ac_2"]); }); - test("an UNRESOLVED toolkit answers as no rows and never falls back to its name", async () => { - // `Gmail` is unresolved, and a row whose provider IS literally `gmail` exists: a fallback to - // the toolkit name would find it. + /** + * AN UNATTRIBUTABLE TOOLKIT REFUSES OUT OF ALL THREE LIFECYCLE MEMBERS, AND NEVER FALLS BACK TO + * ITS NAME. Both halves matter and they used to be answered the same wrong way. + * + * The fallback is still refused: each fixture below serves a row whose instance id IS literally + * the toolkit name folded down, so a fallback would find it and delete it — attributing somebody + * else's connection to this app on a guess. + * + * And answering NO ROWS is refused too, which is this pair's reason for existing. `revoke` then + * said `false` — "there was nothing to withdraw" — without spending a single DELETE, while + * `store.ts`'s disconnect path deletes the local `brokered_connections` row on that answer + * regardless: a live grant at the vendor that no row this deployment holds any longer names. + * `isConnected` and `accountName` said not-connected for the same person, so Connect offered a + * second authorization beside the live one. The listing was not read; refusing is the only + * honest answer, and `broker.ts`'s `revoke` contract requires it. + */ + test("a toolkit whose listing names NO provider refuses rather than answering no rows", async () => { providers({}); listing([row(1, "gmail", { name: "Grace" })]); const deleted: unknown[] = []; @@ -380,12 +395,67 @@ describe("arcade adapter connection lifecycle", () => { }); const { broker } = createArcadeClient("never-dialled"); const toolkit = "Gmail"; - expect(await broker.isConnected({ account, toolkit })).toBe(false); - expect(await broker.accountName({ account, toolkit })).toBeNull(); - expect(await broker.revoke({ account, toolkit })).toBe(false); + const refusal = /names no auth provider at all/; + await expect(broker.isConnected({ account, toolkit })).rejects.toThrow( + refusal, + ); + await expect(broker.accountName({ account, toolkit })).rejects.toThrow( + refusal, + ); + await expect(broker.revoke({ account, toolkit })).rejects.toThrow(refusal); expect(deleted).toEqual([]); }); + test("a toolkit whose listing names TWO providers refuses, naming both", async () => { + // Two actions of one toolkit under two different provider types: the toolkit disagrees with + // itself, so which of this account's connections are its own cannot be read off the listing. + stub( + "list", + (query) => { + const toolkit = (query as { toolkit: string }).toolkit; + return Promise.resolve({ + items: ["microsoft", "google"].map((provider, n) => ({ + qualified_name: `${toolkit}.A${n}`, + requirements: { authorization: { id: "x", provider_id: provider } }, + })), + }); + }, + toolsProto(), + ); + listing([row(1, "gmail", { name: "Grace" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Gmail"; + // Sorted, so the sentence does not change with the order the listing happened to arrive in. + const refusal = /names 2 different auth providers \(google, microsoft\)/; + await expect(broker.isConnected({ account, toolkit })).rejects.toThrow( + refusal, + ); + await expect(broker.accountName({ account, toolkit })).rejects.toThrow( + refusal, + ); + await expect(broker.revoke({ account, toolkit })).rejects.toThrow(refusal); + expect(deleted).toEqual([]); + }); + + test("the unattributable refusal is an AUTHORED one, so the route shows its sentence", async () => { + // A `BrokerRefusalError` and not a bare `Error`: `brokerSentence` is what the Disconnect and + // Connect routes read, and a bare error would have them say "the vendor said nothing about + // why, check the key" about a sentence this deployment wrote itself. + providers({}); + listing([row(1, "google-openbot")]); + const { broker } = createArcadeClient("never-dialled"); + const error = await broker + .revoke({ account, toolkit: "Gmail" }) + .then(() => null) + .catch((thrown: unknown) => thrown); + expect(brokerSentence(error)).toMatch(/names no auth provider at all/); + }); + test("a malformed listing THROWS out of the lifecycle members rather than answering no rows", async () => { stub("list", () => Promise.resolve({ items: "nope" }), toolsProto()); listing([row(1, "google-openbot")]); diff --git a/server/tests/arcade-pagination-engine.test.ts b/server/tests/arcade-pagination-engine.test.ts index 565a60a5c..df0ed1d68 100644 --- a/server/tests/arcade-pagination-engine.test.ts +++ b/server/tests/arcade-pagination-engine.test.ts @@ -521,7 +521,7 @@ describe("the action listing's total_count cross-check survives a short final pa * vanish into the stub. */ describe("the auth provider is read off the WHOLE action listing", () => { - test("a toolkit whose later pages name another provider is left unanswered", async () => { + test("a toolkit whose later pages name another provider refuses, naming both", async () => { const e = engine({ connections: 1, connectionTotal: false, @@ -532,11 +532,22 @@ describe("the auth provider is read off the WHOLE action listing", () => { }); const { broker } = createArcadeClient("never-dialled"); - // UNANSWERED, which the three callers already spell fail-closed: no rows, so not connected, no - // name, and `revoke` answers false WITHOUT SPENDING A DELETE. - expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(false); - expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe(null); - expect(await broker.revoke({ account, toolkit: "Gmail" })).toBe(false); + // UNANSWERED, AND THE THREE CALLERS REFUSE RATHER THAN ANSWER IT. They used to spell it as no + // rows — not connected, no name, `revoke` false with NO DELETE SPENT — and that reading is the + // defect `arcade-revoke-invariant.test.ts` pins: `false` means "there was nothing to withdraw", + // a claim about the vendor's side, while `store.ts` deletes the local row on it and a live + // grant is left at the vendor that nothing names. A listing naming two providers was not read; + // it is refused, in this deployment's own sentence, with both providers in it. + const refusal = /names 2 different auth providers \(google, microsoft\)/; + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(refusal); + await expect( + broker.accountName({ account, toolkit: "Gmail" }), + ).rejects.toThrow(refusal); + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + refusal, + ); expect(deletes(e).length).toBe(0); }); From bbabc97ee4495d4c604d053252f01bcacb1074dc Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:31:34 +0200 Subject: [PATCH 166/232] fix(arcade): an unreadable listing body is not an empty page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sentBody` resolved the vendor's answer as `record(page.body) ?? page`. A NON-JSON 200 — a proxy's courtesy page served with the status of a real answer — parses to a STRING (`internal/parse.js` returns `response.text()` for anything that is not JSON), so no record exists and the expression fell back to the SDK's `OffsetPage`, WHOSE CONSTRUCTOR HAS ALREADY MANUFACTURED `items: []` and `total_count: 0`. "The vendor said there are none" and "the body was unreadable" collapsed into one answer, and every guard written to tell them apart — `pagedItems`' `Array.isArray`, the transport's own — read the manufactured empty list and passed. Measured consequence, reproduced by `arcade-revoke-invariant.test.ts`'s case `a non-JSON 200 response to the connection listing`: `listApps` answers `[]`, `isConnected` answers false, and `revoke` answers `false` WITH NO DELETE SPENT while the caller deletes the local `brokered_connections` row anyway — word for word the live defect fixed on this branch in 28b13e4a, and the outcome `broker.ts:712-717` forbids ("A PARTIAL ASK IS A FAILURE RATHER THAN A TRUE … It must throw, so that the row this deployment holds … is still standing when they press disconnect a second time"). THE FIX IS A CHANGE OF DECISION, NOT OF LINE. The presence of `body` — never whether the body happens to be readable — is what says which kind of answer this is. `AbstractPage`'s constructor always assigns `body`, so the property existing means the answer went through a page constructor and its own `items` and `total_count` are that constructor's inventions; from there the body is the only honest reading, and a body that is not a record means THE ANSWER COULD NOT BE READ. `sentBody` answers `null` for it instead of handing back the page. THE COMPATIBILITY CASE THE FALLBACK EXISTED FOR IS KEPT, not deleted. `admin.authProviders.list` returns a plain `APIPromise` — the parsed body ITSELF, no `body` property, nothing manufactured — as do all the hand-built stubs in the adapter suite. Those still read through the answer's own properties, and a new case pins it: `isConnected` answers `true` off a `google-openbot` instance resolved from that plain body. NO NEW ERROR SHAPE. `sentBody` answering `null` routes into the refusals already on this seam — `pagedItems`' per-listing `malformed` sentence, and `listTools`' transport-side `Array.isArray` guard — so an unreadable body refuses in exactly the words a body with no `items` already refuses in. CALL SITES OF WHAT CHANGED (all file-local; `sentBody`, `sentItemsOf` and `reportedTotalOf` are not exported, and no other file reads them): sentBody - reportedTotalOf (arcade-adapter.ts:184) - sentItemsOf (arcade-adapter.ts:194) sentItemsOf / reportedTotalOf - pagedItems (:245, :249) — the generator every paged listing goes through - createArcadeClient's `actions.listTools` (:1007, :1008) pagedItems, and so every reader that now refuses an unreadable body: - listApps (:409) catalogue listing, `client.tools.list` - authProviderInstances (:538) `admin.authProviders.list` — THE COMPATIBILITY PATH: a plain APIPromise, unchanged by this - connectionsFor (:633) `admin.userConnections.list` — the A3 route; reached by broker.isConnected, accountName, grantsFor and revoke - grantsFor (:870) `client.tools.list` per toolkit - authProviderOf (:945) `client.tools.list` per toolkit actions.listTools — consumed by `./arcade`'s `listTools`, whose own `Array.isArray` guard now writes its sentence for a non-JSON body too, instead of refreshing an app's actions down to none. AUDIT OF THE WHOLE FILE for the same decision ("nullish body means empty"): `sentBody` was its only site. The remaining `??`s are display fallbacks (`textOf(toolkit?.description) ?? ""`, `textOf(info?.name) ?? textOf(info?.login)`) and the documented named-field defaults on `auth.status`, which is a plain APIPromise where nothing is manufactured. VERIFICATION - Red-green: the four new non-JSON cases in `arcade-malformed-listing-engine.test.ts` fail before (9 pass / 4 fail) and pass after (13 pass / 0 fail). - `arcade-revoke-invariant.test.ts`: 3 pass / 4 fail → 4 pass / 3 fail. The named case goes green; the other six are unchanged, including the three that are green only by raw `TypeError` (204, JSON `null`, `items: [null]`) — left alone deliberately. The file itself is NOT touched. - Green: arcade-adapter (11), arcade-adapter-connections (20), arcade-pagination-engine (18), arcade-listing (5), arcade-malformed-listing-engine (13), arcade-empty-vendor-structures-engine (7), arcade-adapter-errors (7), plugin-logos.integration (4) — all 0 fail. - `bun run test:server:sharded`: the only failures are the three invariant cases above and three `learning-*` agent-integration tests that spawn external agent processes, verified failing identically at base 1d80c6ad and importing nothing from this adapter. - typecheck 0, typecheck:tests 0, lint 0, format:check 0. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 24 ++- .../arcade-malformed-listing-engine.test.ts | 163 ++++++++++++++++-- 2 files changed, 173 insertions(+), 14 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index e3a98786a..4d5e1d171 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -150,11 +150,33 @@ const CATALOGUE_MAX_PAGES = 1000; * there. The answer's own properties are the fallback for an answer that has NO `body` at all, * which is what `admin.authProviders.list` (a plain `APIPromise`, not a page) and every hand-built * stub return — those were never passed through a page constructor, so nothing was manufactured. + * + * AND THE PRESENCE OF `body` IS WHAT DECIDES WHICH OF THE TWO THIS IS — never whether the body + * happens to be readable. `AbstractPage`'s constructor always assigns `body`, so the property + * existing is the answer saying "I went through a page constructor, and my own `items` and + * `total_count` are that constructor's inventions". From there the body is the ONLY honest + * reading, and a body that is not a record means THE ANSWER COULD NOT BE READ — it does not mean + * the answer was empty. + * + * MEASURED, because this is the hole the fallback left and it is the live defect again: a 200 + * carrying `content-type: text/html` — a proxy's courtesy page — parses to a STRING + * (`internal/parse.js` returns `response.text()` for anything that is not JSON), so no record + * exists anywhere and a `?? page` fallback hands back the page's manufactured `items: []`. + * `listApps` then answered `[]`, `isConnected` answered false, and `revoke` answered `false` WITH + * NO DELETE SPENT while the caller deleted the local row — `broker.ts:712-717`'s forbidden + * outcome, reached by a route neither contributing guard could see, because both were written + * against MALFORMED JSON and this is the branch where the JSON was never parsed at all. + * + * ANSWERING `null` IS THE REFUSAL, and deliberately not a throw from here: every caller already + * has this deployment's own sentence for an unreadable listing — {@link pagedItems}' per-listing + * `malformed`, and the transport's own `Array.isArray` guard for `listTools` — and routing through + * them is what keeps one vocabulary on this seam instead of a second error shape invented here. */ function sentBody(answer: unknown): Record | null { const page = record(answer); if (!page) return null; - return record(page.body) ?? page; + if ("body" in page) return record(page.body); + return page; } /** The `total_count` Arcade itself sent, or nothing. Read through {@link sentBody}, for its reason. */ diff --git a/server/tests/arcade-malformed-listing-engine.test.ts b/server/tests/arcade-malformed-listing-engine.test.ts index 9ec146a1c..cd031eaee 100644 --- a/server/tests/arcade-malformed-listing-engine.test.ts +++ b/server/tests/arcade-malformed-listing-engine.test.ts @@ -15,9 +15,21 @@ import type { AccountRef } from "../src/plugins/shared-accounts"; * passes whether the guard reads the page or the body it was built from. * * So, as in `arcade-pagination-engine.test.ts`, the engine here serves real JSON over a real socket - * through `ARCADE_BASE_URL` and the SDK builds its own pages. The malformed body is `{"error": - * "nope"}`: a well-formed JSON object, 200, with no `items` — what an engine answering an error - * shape on the success path sends. + * through `ARCADE_BASE_URL` and the SDK builds its own pages. + * + * TWO UNREADABLE BODIES, NOT ONE, because they reach the guard down DIFFERENT routes and only the + * first was ever covered: + * + * - `{"error": "nope"}` — a well-formed JSON object, 200, with no `items`: what an engine + * answering an error shape on the success path sends. The SDK parses it, so the page's `body` IS + * a record and the guard reads a real body that simply has no list on it. + * - `OK` with `content-type: text/html` and a 200 — a proxy's courtesy + * page wearing a real answer's status. `defaultParseResponse` hands a STRING to the page + * constructor, so there is no record to read anywhere; the honest body is unreadable and the only + * `items` in existence are the ones `OffsetPage`'s constructor manufactured. A reader that falls + * back to the page object for want of a record therefore answers `[]` — "the vendor says there + * are none" — for a body it never read, and `revoke` returns `false` with no DELETE spent. That + * is the live defect, reached through the branch the JSON case above can never enter. */ const account: AccountRef = { @@ -26,13 +38,37 @@ const account: AccountRef = { vendorUserId: "vendor-u1", }; -/** Which of the three listings answers the malformed body. */ -type Malformed = "tools" | "connections" | "authProviders"; +/** Which of the three listings answers the malformed body, or none of them. */ +type Malformed = "tools" | "connections" | "authProviders" | "none"; + +/** WHICH unreadable body that listing answers with. See this file's opening note. */ +type Unreadable = "json-without-items" | "not-json"; type Engine = { port: number; stop: () => void }; const MALFORMED = { error: "nope" }; +/** The unreadable answer, served with the 200 of a real one either way. */ +const unreadable = (kind: Unreadable): Response => + kind === "json-without-items" + ? Response.json(MALFORMED) + : new Response("OK", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + +/** A live connection row held under the `google-openbot` INSTANCE of the `google` provider. */ +const connectionRow = () => ({ + id: "at_0", + connection_id: "ac_0", + connection_status: "active", + provider_id: "google-openbot", + provider_type: "oauth2", + provider_user_info: { name: "person 0" }, + scopes: [], + user_id: account.vendorUserId, +}); + const toolRow = (toolkit: string) => ({ qualified_name: `${toolkit}.Action0`, description: "", @@ -43,7 +79,7 @@ const toolRow = (toolkit: string) => ({ }, }); -function startEngine(malformed: Malformed): Engine { +function startEngine(malformed: Malformed, kind: Unreadable): Engine { const server = Bun.serve({ port: 0, fetch(request) { @@ -51,12 +87,24 @@ function startEngine(malformed: Malformed): Engine { const limit = Number(url.searchParams.get("limit") ?? "100"); const offset = Number(url.searchParams.get("offset") ?? "0"); + // The DELETE, always available, so "no DELETE was spent" is a statement about the adapter. + if (url.pathname.startsWith("/v1/admin/user_connections/")) { + return new Response(null, { status: 204 }); + } + if (url.pathname === "/v1/admin/user_connections") { - if (malformed === "connections") return Response.json(MALFORMED); - return Response.json({ items: [], offset, limit, page_count: 0 }); + if (malformed === "connections") return unreadable(kind); + const items = + malformed === "none" && offset === 0 ? [connectionRow()] : []; + return Response.json({ + items, + offset, + limit, + page_count: items.length, + }); } if (url.pathname === "/v1/admin/auth_providers") { - if (malformed === "authProviders") return Response.json(MALFORMED); + if (malformed === "authProviders") return unreadable(kind); return Response.json({ items: [ { id: "google-openbot", provider_id: "google", type: "oauth2" }, @@ -68,7 +116,7 @@ function startEngine(malformed: Malformed): Engine { }); } if (url.pathname === "/v1/tools") { - if (malformed === "tools") return Response.json(MALFORMED); + if (malformed === "tools") return unreadable(kind); const toolkit = url.searchParams.get("toolkit") ?? "Gmail"; const page = offset === 0 ? [toolRow(toolkit)] : []; return Response.json({ @@ -81,14 +129,23 @@ function startEngine(malformed: Malformed): Engine { return new Response("not found", { status: 404 }); }, }); - return { port: server.port, stop: () => server.stop(true) }; + // `Bun.serve().port` is `number | undefined`. Refuse loudly rather than coercing: a stub that + // never bound a port would send every request of this file to a nonsense address, and the + // refusals below would then "hold" because nothing reached a vendor at all. + const { port } = server; + if (port === undefined) + throw new Error("the stub vendor did not bind a port"); + return { port, stop: () => server.stop(true) }; } let running: Engine | null = null; const wasBaseURL = process.env.ARCADE_BASE_URL; -function engine(malformed: Malformed): void { - running = startEngine(malformed); +function engine( + malformed: Malformed, + kind: Unreadable = "json-without-items", +): void { + running = startEngine(malformed, kind); process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; } @@ -168,3 +225,83 @@ describe("a listing with no items refuses rather than reading as empty", () => { ); }); }); + +/** + * THE SAME SENTENCES, OFF A BODY THAT IS NOT JSON AT ALL. + * + * Every case above serves a body the SDK successfully PARSES, so the page carries a readable + * record and a reader that takes the page's own `items` when it finds no record never has to + * decide anything. A non-JSON 200 is the branch where that decision is actually made: the parsed + * body is a STRING, no record exists, and `OffsetPage`'s manufactured `items: []` is the only list + * on the object. The rule these pin is that AN UNREADABLE BODY IS NOT AN EMPTY PAGE — it refuses + * in the same sentence a body with no `items` refuses in, because the two are the same fact about + * what this deployment was told. + */ +describe("a listing whose body is not JSON refuses rather than reading as empty", () => { + test("listApps refuses a non-JSON tool listing", async () => { + engine("tools", "not-json"); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.listApps()).rejects.toThrow( + /Arcade did not answer with a tool list/, + ); + }); + + test("the transport refuses a non-JSON tool listing", async () => { + engine("tools", "not-json"); + const { actions } = createArcadeClient("never-dialled"); + useArcadeClient(actions); + + await expect(listTools({ url: "arcade://Gmail" })).rejects.toThrow( + /Arcade did not answer with an action list for Gmail\. Nothing was refreshed/, + ); + }); + + test("isConnected refuses a non-JSON auth-provider listing", async () => { + engine("authProviders", "not-json"); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/Arcade did not answer with an auth-provider list/); + }); + + test("isConnected refuses a non-JSON connection listing", async () => { + engine("connections", "not-json"); + const { broker } = createArcadeClient("never-dialled"); + + await expect( + broker.isConnected({ account, toolkit: "Gmail" }), + ).rejects.toThrow(/Arcade did not answer with a connection list/); + }); + + test("revoke refuses a non-JSON connection listing rather than answering false", async () => { + // THE LIVE DEFECT'S EXACT SIGNATURE: `false` with no DELETE spent, while the caller deletes + // the local row anyway and a live grant at the vendor is left with nothing naming it. + engine("connections", "not-json"); + const { broker } = createArcadeClient("never-dialled"); + + await expect(broker.revoke({ account, toolkit: "Gmail" })).rejects.toThrow( + /Arcade did not answer with a connection list/, + ); + }); +}); + +/** + * THE COMPATIBILITY CASE THE PAGE FALLBACK EXISTS FOR, PINNED SO IT CANNOT BE DELETED WITH IT. + * + * `admin.authProviders.list` returns a plain `APIPromise` — the parsed + * body ITSELF, never a page — so it has no `body` property to read through and its own properties + * are the honest ones. Nothing was manufactured for it, so reading it directly is not a guess. + * `authProviderInstances` is the only reader of it, and `isConnected` answering TRUE here is the + * whole chain saying so: the `google` type resolved to the `google-openbot` INSTANCE off that + * body, and the connection row matched it. + */ +describe("a listing that is its own body is still read", () => { + test("isConnected reads the auth-provider configuration off a plain APIPromise", async () => { + engine("none"); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + }); +}); From 83a9e2547ed1274976726e73a9b31e11a99a2276 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:31:48 +0200 Subject: [PATCH 167/232] fix(arcade): read a vendor total_count the cross-check can use, or refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A9. The truncation cross-check in `pagedItems` has exactly one input — the `total_count` the vendor sent — and `reportedTotalOf` read it as `typeof raw === "number" ? raw : undefined`. That folded "the vendor did not say" together with "the vendor said something this deployment cannot read", and every caller treats the first as "there is nothing to check against". So a vendor spelling the claim `"total_count": "250"` switched the check off. Measured through the invariant harness's own request log: 250 connections held, 100 served, `revoke` returned TRUE after 100 DELETEs with 150 grants still live at the vendor. That is worse than the `false`-with-no-DELETE defect this seam has suffered before — that one is at least visibly wrong. This one reports a complete withdrawal to the operator while 60% of the grants stay live, with no signal anywhere. Probe 8-1 F7 saw the disabled cross-check and filed it as a cross-check gap; nobody joined it to the revoke outcome. THE READING CHOSEN: a plain decimal integer, string or number alike, is a legitimate vendor spelling and is COERCED; anything else the vendor actually SENT is unreadable and REFUSES; an ABSENT field still means silence. Coercion over blanket refusal, because refusing `"250"` protects shape 5 alone and leaves the truncation it conceals undetected, while permanently breaking Disconnect for any deployment whose vendor always spells it that way — trading a silent partial for a hard failure with no way through. Coercing makes the check FIRE: the 100-of-250 listing now throws the existing `incomplete` sentence before a single DELETE is spent, and the local row survives to name the app on a second press. Strictness is what keeps the hole shut: `"many"`, `null`, an object, `true`, `"1e3"`, `""`, a negative and a fraction all refuse in this deployment's own voice rather than silently re-disabling the check. AND THE NUMBER BRANCH NOW CHECKS THE VALUE, not just the type. `NaN` passed the old `typeof` test and disabled the check exactly as a string did (`seen < NaN` is false), and a negative did the same; both are unreadable now. Whole-file audit for the same pattern — a vendor-supplied count, total or page number silently treated as absent when it is not a number: `total_count` was the only one. `page_count` is never read; every `offset` passed to the vendor is this file's own `seen` and never echoed back from a body; `normalizeArcadeError`'s `typeof current.status === "number"` is an HTTP status, not a count, and its undefined branch is the deliberate "no response was read" wording the authorize route keys off. Call sites, all of them: - `pagedItems` — now takes each listing's own `unreadableTotal` sentence: - `listApps` (app catalogue) - `authProviderInstances` (auth-provider configuration listing) - `connectionsFor` (user-connection listing — the revoke path) - `grantsFor` (per-account action listing) - `authProviderOf` (toolkit action listing) - `createArcadeClient().actions.listTools` — refuses in place rather than dropping the field, which would switch off the transport's own `raw.length < total` check one layer out. `server/tests/arcade-revoke-invariant.test.ts` is NOT touched. Its case `total_count sent as a JSON string rather than a number` goes green because the production code changed: 3 pass / 4 fail before, 4 pass / 3 fail after. The three remaining failures (non-JSON 200, zero provider ids, two provider ids) are unchanged and belong to other fixes in this wave. New covering test `server/tests/arcade-unreadable-total-engine.test.ts`, served over a real socket because `OffsetPage` manufactures `total_count: 0` for an absent field: 2 pass / 9 fail before this change, 11 pass / 0 fail after. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 105 +++++++- .../arcade-unreadable-total-engine.test.ts | 255 ++++++++++++++++++ 2 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 server/tests/arcade-unreadable-total-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index e3a98786a..829c8673c 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -157,10 +157,73 @@ function sentBody(answer: unknown): Record | null { return record(page.body) ?? page; } -/** The `total_count` Arcade itself sent, or nothing. Read through {@link sentBody}, for its reason. */ -function reportedTotalOf(answer: unknown): number | undefined { +/** A count's decimal spelling and nothing else: no sign, no exponent, no separator, no point. */ +const COUNT_TEXT = /^[0-9]+$/; + +/** + * A COUNT ARCADE SENT, READ AS THREE OUTCOMES AND NEVER TWO: the number, `undefined` for a field + * the vendor did not send, and `"unreadable"` for one it DID send in a shape this cannot read. + * + * THE THIRD OUTCOME IS THE WHOLE POINT. The previous reading was `typeof raw === "number" ? raw : + * undefined`, which folded "the vendor did not say" together with "the vendor said something this + * deployment cannot read" — and every caller treats the first as "there is nothing to cross-check + * against". Measured: a vendor that serves 100 of 250 connections and spells the claim + * `"total_count": "250"` had its truncation cross-check silently switched off, and `revoke` + * DELETED 100, ANSWERED `true`, AND LEFT 150 GRANTS LIVE with no signal anywhere that the + * withdrawal was partial. That is strictly worse than the `false`-with-no-DELETE defect this file + * is full of warnings about: that one is at least visibly wrong. + * + * A NUMERIC STRING IS A LEGITIMATE SPELLING AND IS COERCED. `"250"` is the vendor saying 250 — it + * is unambiguous, it is what a serializer that renders integers as text sends, and refusing it + * would take Disconnect permanently offline for a deployment whose vendor always spells it so, + * trading a silent partial for a hard failure with no way through. Coercing instead makes the + * cross-check FIRE, which is what actually protects the invariant: the 250-of-100 listing above + * now throws the `incomplete` sentence, no DELETE is spent, and the local row survives. + * + * EVERYTHING ELSE PRESENT REFUSES, and the strictness is deliberate, because a lenient parse is + * how this defect comes back. `"many"`, `null`, an object, `true`, `NaN`, `Infinity`, a negative, + * a fraction and `"1e3"` are all unreadable: anything that is not a plain non-negative decimal + * integer is a value whose meaning this deployment would be guessing at. `NaN` in particular was + * already slipping through the old `typeof` test and disabling the cross-check the same way a + * string did — `seen < NaN` is false — so the number branch checks the VALUE and not just the type. + * + * ABSENT STAYS ABSENT. `total_count` is optional on the wire and the listings here end on a short + * page, never on a total; a missing field must go on meaning "the vendor did not say" or every + * engine that omits it would refuse. + */ +function countOf(raw: unknown): number | "unreadable" | undefined { + if (raw === undefined) return undefined; + if (typeof raw === "string") { + const text = raw.trim(); + if (!COUNT_TEXT.test(text)) return "unreadable"; + const parsed = Number(text); + return Number.isSafeInteger(parsed) ? parsed : "unreadable"; + } + if (typeof raw !== "number") return "unreadable"; + return Number.isSafeInteger(raw) && raw >= 0 ? raw : "unreadable"; +} + +/** What the vendor sent, written into a refusal without pasting an object into a sentence. */ +function shownValue(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value); + if (value !== null && typeof value === "object") return "an object"; + return String(value); +} + +/** + * The `total_count` Arcade itself sent, or nothing. Read through {@link sentBody}, for its reason, + * and through {@link countOf}, for ITS reason: a total the vendor sent in a shape this cannot read + * THROWS the caller's own sentence rather than answering `undefined` and disabling the only + * cross-check that can tell a truncated listing from a whole one. + */ +function reportedTotalOf( + answer: unknown, + unreadable: (shown: string) => string, +): number | undefined { const raw = sentBody(answer)?.total_count; - return typeof raw === "number" ? raw : undefined; + const count = countOf(raw); + if (count === "unreadable") throw new Error(unreadable(shownValue(raw))); + return count; } /** @@ -197,6 +260,12 @@ type Listing = { malformed: string; unending: string; incomplete: (seen: number, total: number) => string; + /** + * What this deployment says when the vendor sent a `total_count` it cannot read. A sentence of + * its own rather than a reuse of `malformed`: the list itself was fine and the CLAIM ABOUT ITS + * LENGTH was not, and a reader sent looking for a broken list would find a well-formed one. + */ + unreadableTotal: (shown: string) => string; }; /** @@ -204,7 +273,9 @@ type Listing = { * * PAGINATION ENDS ON A SHORT PAGE, never on `total_count`, which is optional on the wire; the * reported total is a CROSS-CHECK and is taken through {@link reportedTotalOf} and - * {@link highestTotal} so that "the vendor did not say" can never read as "the vendor said zero". + * {@link highestTotal} so that "the vendor did not say" can never read as "the vendor said zero" + * — nor, through {@link countOf}, as "the vendor said something unreadable". A cross-check with no + * input is not a cross-check, and the listing it would have caught is the truncated one. * * THE SDK'S OWN AUTO-PAGINATION IS NOT USED by anything that goes through here, and that is the * point: `OffsetPage.nextPageRequestOptions` returns `null` the moment `total_count` is falsy, so @@ -224,7 +295,10 @@ async function* pagedItems(listing: Listing): AsyncGenerator { if (!Array.isArray(items)) throw new Error(listing.malformed); yield* items; seen += items.length; - total = highestTotal(total, reportedTotalOf(answer)); + total = highestTotal( + total, + reportedTotalOf(answer, listing.unreadableTotal), + ); if (items.length < listing.pageSize) { ended = true; break; @@ -418,6 +492,8 @@ async function listApps(client: Arcade): Promise { unending: `Arcade's catalogue never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, incomplete: (seen, total) => `Arcade listed ${seen} of ${total} actions, so its app catalogue is incomplete.`, + unreadableTotal: (shown) => + `Arcade reported its app catalogue's total_count as ${shown}, which is not a count, so whether the catalogue was listed in full could not be checked.`, })) { const toolkit = ( item as { toolkit?: { name?: unknown; description?: unknown } } | null @@ -534,6 +610,8 @@ async function authProviderInstances( unending: `Arcade's auth-provider list never ended after ${AUTH_PROVIDER_MAX_PAGES} pages, so it is not trusted.`, incomplete: (seen, total) => `Arcade listed ${seen} of ${total} auth providers, so its configuration list is incomplete.`, + unreadableTotal: (shown) => + `Arcade reported its auth-provider list's total_count as ${shown}, which is not a count, so whether the configurations of ${authProvider} were listed in full could not be checked.`, })) { const config = record(item); const instance = textOf(config?.id); @@ -632,6 +710,8 @@ async function connectionsFor( unending: `Arcade's connection list never ended after ${CONNECTION_MAX_PAGES} pages, so it is not trusted.`, incomplete: (seen, total) => `Arcade listed ${seen} of ${total} connections, so what this account holds at ${toolkit} could not be read in full.`, + unreadableTotal: (shown) => + `Arcade reported its connection list's total_count as ${shown}, which is not a count, so whether what this account holds at ${toolkit} was listed in full could not be checked.`, })) { const row = item as ConnectionRow; const instance = textOf(row.provider_id)?.toLowerCase(); @@ -867,6 +947,8 @@ async function grantsFor( unending: `Arcade's action list for ${toolkit} never ended after ${CATALOGUE_MAX_PAGES} pages, so it is not trusted.`, incomplete: (seen, total) => `Arcade listed ${seen} of ${total} actions for ${toolkit}, so what this account holds there could not be read in full.`, + unreadableTotal: (shown) => + `Arcade reported the action list for ${toolkit} with a total_count of ${shown}, which is not a count, so whether what this account holds there was listed in full could not be checked.`, })) { grants.push(...grantStateOf(item)); } @@ -936,6 +1018,8 @@ async function authProviderOf( unending: `Arcade's action list for ${toolkit} never ended after ${CATALOGUE_MAX_PAGES} pages, so the auth provider its connections are held under is not trusted.`, incomplete: (seen, total) => `Arcade listed ${seen} of ${total} actions for ${toolkit}, so the auth provider its connections are held under could not be read in full.`, + unreadableTotal: (shown) => + `Arcade reported the action list for ${toolkit} with a total_count of ${shown}, which is not a count, so whether the auth provider its connections are held under could be read in full is not known.`, })) { const authorization = record(record(item)?.requirements)?.authorization; const provider = textOf(record(authorization)?.provider_id); @@ -983,7 +1067,16 @@ export function createArcadeClient(apiKey: string): { // guard as dead as the totals the comment above describes, and a listing that answered no // items at all would refresh an app's actions down to none. const items = sentItemsOf(answer) as unknown[]; - const total = reportedTotalOf(answer); + // AND A TOTAL THIS CANNOT READ REFUSES HERE TOO, rather than being dropped on the way out. + // The declared `total_count?: number` leaves the transport exactly two readings — a number + // to cross-check against, or "the vendor did not say" — so a value that is neither has + // nowhere honest to go: omitting it would switch off the transport's own + // `raw.length < total` check and commit a truncated refresh as a whole catalogue. + const total = reportedTotalOf( + answer, + (shown) => + `Arcade reported the action listing for ${q.toolkit} with a total_count of ${shown}, which is not a count, so whether the listing is whole could not be checked. Nothing was refreshed and the actions already recorded for this app are kept.`, + ); return total === undefined ? { items } : { items, total_count: total }; }, execute: async (r) => diff --git a/server/tests/arcade-unreadable-total-engine.test.ts b/server/tests/arcade-unreadable-total-engine.test.ts new file mode 100644 index 000000000..0ed00f06f --- /dev/null +++ b/server/tests/arcade-unreadable-total-engine.test.ts @@ -0,0 +1,255 @@ +// server/tests/arcade-unreadable-total-engine.test.ts +/** + * THE TRUNCATION CROSS-CHECK IS ONLY AS GOOD AS THE VALUE IT READS. + * + * `pagedItems` ends a listing on a short page and then asks one further question — did the vendor + * claim more rows than arrived? — and that question has exactly one input: `total_count`. The + * reading of that field used to be `typeof raw === "number" ? raw : undefined`, which answered + * "the vendor did not say" for every shape that is not a JavaScript number. A vendor serving 100 + * of 250 connections under `"total_count": "250"` therefore had the cross-check switched off, and + * `revoke` DELETED 100, ANSWERED `true`, AND LEFT 150 GRANTS LIVE — a partial withdrawal reported + * to the operator as a clean success, which is the one outcome `broker.ts`'s `revoke` contract + * forbids outright. + * + * This file pins the three readings the field now has, through the public broker seam: + * + * - a plain decimal integer, string or number alike, IS the vendor saying a number, and the + * cross-check fires on it; + * - anything else the vendor SENT is unreadable and REFUSES, so the check can never be disabled + * again by a value nobody anticipated; + * - an ABSENT field still means "the vendor did not say", because `total_count` is optional on + * the wire and every listing here ends on a short page. + * + * SERVED OVER A REAL SOCKET, for `arcade-pagination-engine.test.ts`'s reason: `OffsetPage`'s + * constructor manufactures `total_count: 0` for a field the vendor never sent, so only the wire + * can pose these questions. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +const CONNECTION_PAGE_SIZE = 100; + +const connectionRow = (n: number) => ({ + id: `at_${n}`, + connection_id: `ac_${n}`, + connection_status: "active", + provider_id: "google-openbot", + provider_type: "oauth2", + provider_user_info: { name: `person ${n}` }, + scopes: [], + user_id: account.vendorUserId, +}); + +const toolRow = (toolkit: string, n: number) => ({ + qualified_name: `${toolkit}.Action${n}`, + description: "", + toolkit: { name: toolkit, description: "" }, + input: { parameters: [] }, + requirements: { + authorization: { provider_id: "google", oauth2: { scopes: [] } }, + }, +}); + +type Engine = { port: number; requests: string[]; stop: () => void }; + +/** + * The vendor, serving `held` connection rows but only the first `served` of them, and reporting + * the holding as `total` — whatever shape `total` is in. `undefined` omits the field entirely. + */ +function startEngine(held: number, served: number, total: unknown): Engine { + const requests: string[] = []; + const rows = Array.from({ length: held }, (_, n) => connectionRow(n)); + + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}`); + const offset = Number(url.searchParams.get("offset") ?? "0"); + const limit = Number( + url.searchParams.get("limit") ?? String(CONNECTION_PAGE_SIZE), + ); + + if (url.pathname.startsWith("/v1/admin/user_connections/")) { + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/v1/admin/user_connections") { + const page = rows.slice(offset, Math.min(offset + limit, served)); + const body: Record = { + items: page, + offset, + limit, + page_count: page.length, + }; + // The one varying thing. `undefined` disappears from the JSON, which is the vendor that + // never sent the field at all. + if (total !== undefined) body.total_count = total; + return Response.json(body); + } + + if (url.pathname === "/v1/admin/auth_providers") { + return Response.json({ + items: [ + { id: "google-openbot", provider_id: "google", type: "oauth2" }, + ], + offset: 0, + limit, + page_count: 1, + total_count: 1, + }); + } + + if (url.pathname === "/v1/tools") { + const toolkit = url.searchParams.get("toolkit") ?? "Gmail"; + const page = + offset === 0 + ? Array.from({ length: 4 }, (_, n) => toolRow(toolkit, n)) + : []; + return Response.json({ + items: page, + offset, + limit, + page_count: page.length, + total_count: 4, + }); + } + + return new Response("not found", { status: 404 }); + }, + }); + const { port } = server; + if (port === undefined) + throw new Error("the stub vendor did not bind a port"); + return { port, requests, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(held: number, served: number, total: unknown): Engine { + running = startEngine(held, served, total); + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +const deletesSpent = (e: Engine): number => + e.requests.filter( + (r) => r.startsWith("DELETE ") && r.includes("/v1/admin/user_connections/"), + ).length; + +type Outcome = + | { kind: "returned"; value: boolean } + | { kind: "threw"; message: string }; + +async function revokeOutcome( + broker: ReturnType["broker"], +): Promise { + try { + return { + kind: "returned", + value: await broker.revoke({ account, toolkit: "Gmail" }), + }; + } catch (error) { + return { + kind: "threw", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +describe("a total_count the vendor sent is read, or refused, but never ignored", () => { + /** + * THE DEFECT ITSELF. 250 held, 100 served, the claim spelled as a string. Before the fix this + * answered `true` after 100 DELETEs; the cross-check must fire instead, and it must fire BEFORE + * any DELETE is spent, so the local row survives to name the app on a second press. + */ + test("a numeric string is the vendor saying a number, and the cross-check fires on it", async () => { + const e = engine(250, 100, "250"); + const { broker } = createArcadeClient("never-dialled"); + + const outcome = await revokeOutcome(broker); + + expect(outcome.kind).toBe("threw"); + if (outcome.kind !== "threw") return; + expect(outcome.message).toBe( + "Arcade listed 100 of 250 connections, so what this account holds at Gmail could not be read in full.", + ); + expect(deletesSpent(e)).toBe(0); + }); + + /** The same spelling on a WHOLE listing is not an error: it reads as the number it is. */ + test("a numeric string on a whole listing revokes normally", async () => { + const e = engine(3, 3, "3"); + const { broker } = createArcadeClient("never-dialled"); + + expect(await revokeOutcome(broker)).toEqual({ + kind: "returned", + value: true, + }); + expect(deletesSpent(e)).toBe(3); + }); + + /** + * EVERY OTHER VALUE THE VENDOR SENT REFUSES. Each of these would, under a lenient reading, go + * back to answering "the vendor did not say" and re-open the defect above verbatim — so the + * refusal is the assertion, and the listing underneath is a WHOLE one to make the point that it + * is the claim about the length, not the list, that could not be read. + */ + const unreadable: [string, unknown, string][] = [ + ["a word", "many", '"many"'], + ["the literal null", null, "null"], + ["an object", { count: 3 }, "an object"], + ["a boolean", true, "true"], + ["a negative", -1, "-1"], + ["a fraction", 1.5, "1.5"], + ["an exponent spelling", "1e3", '"1e3"'], + ["an empty string", "", '""'], + ]; + for (const [name, value, shown] of unreadable) { + test(`total_count as ${name} refuses rather than disabling the cross-check`, async () => { + const e = engine(3, 3, value); + const { broker } = createArcadeClient("never-dialled"); + + const outcome = await revokeOutcome(broker); + + expect(outcome.kind).toBe("threw"); + if (outcome.kind !== "threw") return; + expect(outcome.message).toBe( + `Arcade reported its connection list's total_count as ${shown}, which is not a count, so whether what this account holds at Gmail was listed in full could not be checked.`, + ); + expect(deletesSpent(e)).toBe(0); + }); + } + + /** + * AND AN ABSENT FIELD IS STILL SILENCE. `total_count` is optional on the wire; refusing for its + * absence would take every engine that omits it offline, which is the opposite mistake. + */ + test("an omitted total_count still means the vendor did not say", async () => { + const e = engine(3, 3, undefined); + const { broker } = createArcadeClient("never-dialled"); + + expect(await revokeOutcome(broker)).toEqual({ + kind: "returned", + value: true, + }); + expect(deletesSpent(e)).toBe(3); + }); +}); From 293d4f10e930ba42e73d92fb760ace9a08a32dda Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:32:21 +0200 Subject: [PATCH 168/232] fix(arcade): refuse a vendor body this deployment cannot read, instead of crashing on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three shapes on the revoke seam satisfied `arcade-revoke-invariant.test.ts` BY ACCIDENT: a 204 with `content-length: 0`, a body whose JSON is the literal `null`, and `items: [null]` on the user-connections listing. The adapter did not refuse any of them — it raised a bare `TypeError: null is not an object`, and the invariant held only because a crash is technically "not answering". That is a latent trap, not a live defect, and it is one tidy-up from being a live one: a `?? []` or a silent skip on either cast — exactly the change a reviewer waves through as defensive hygiene — turns all three into `revoke` answering `false` with no DELETE spent while the caller deletes the local `brokered_connections` row, which `broker.ts:712-717` forbids and which `28b13e4a` already fixed once on this branch. A raw `TypeError` is also the wrong operator experience: a vendor stack trace where this deployment's sentence belongs, indistinguishable in the logs from a programmer error here. Affected call sites (server/src/plugins/arcade-adapter.ts): - `vendorCall` (93-176) — a `TypeError` with NO `APIError` anywhere in its cause chain is now the deployment's `unreadableBody(step)` sentence rather than an escape. `normalizeArcadeError` is still asked FIRST, which is the whole of the old docblock's warning against catching "by shape": nothing reaching the new branch carries a vendor response at all. This is where the 204 and the JSON `null` land — both reach `OffsetPage`'s constructor as `body === null`, inside `@arcadeai/arcadejs`, before one line of this file reads anything. - `unreadableBody` / `requireBody` (93-129, new) — one sentence for "Arcade answered, and the answer had no body in it", in the uncertainty register (`is not known here`), and the guard the non-paged calls need. No fallback to an empty list anywhere: that is the defect, not the fix. - `shapeOf` (356-360) — `typeof null` is `"object"`, so a hole read back as "a object". A hole is now "nothing". No existing caller can pass one. - `connectionsFor` (696-723) — `item as ConnectionRow` followed by `row.provider_id` was the `items: [null]` crash. The row must now BE an object (arrays refused too, since a list has no `provider_id` and would be dropped silently, reading the listing short); an unreadable row refuses in the register of this listing's own `malformed` sentence. - `broker.authorize` (1140-1150), `broker.authorizations.status` (1268-1276), `broker.authorizations.confirmReturn` (1295-1309) — the audit's other instances of the same pattern. These are not paged, so they go through no `OffsetPage` constructor: the SDK hands a literal `null` back WEARING its declared response type, and `answer.status` / `answer.scopes` / `answer.auth_id` were the identical unguarded dereference raising the identical bare `TypeError`, from inside this file instead. Each now reads its body through `requireBody`; every field-level guard below them is unchanged. Deliberately NOT touched: the non-JSON-200 path and the `total_count` path, which are two other agents' changes in this same file; and `server/tests/arcade-revoke-invariant.test.ts`, which is the specification. New: `server/tests/arcade-unreadable-body-engine.test.ts`, served over a real socket (a hand-built stub cannot pose these shapes), asserting the SENTENCE and not merely that something was thrown — 0 pass / 4 fail before this change, all four reporting `TypeError`; 4 pass after. The invariant file is unchanged at 3 pass / 4 fail, the same four red cases as at `1d80c6ad`. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/arcade-adapter.ts | 150 +++++++++-- .../arcade-unreadable-body-engine.test.ts | 239 ++++++++++++++++++ 2 files changed, 367 insertions(+), 22 deletions(-) create mode 100644 server/tests/arcade-unreadable-body-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index e3a98786a..0febfee69 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -90,6 +90,43 @@ export function normalizeArcadeError(error: unknown): ArcadeFailure | null { return null; } +/** + * THIS DEPLOYMENT'S ONE SENTENCE FOR "ARCADE ANSWERED, AND THE ANSWER HAD NO BODY IN IT". + * + * ONE SENTENCE AND NOT SEVERAL, because the fact is one fact wherever it lands: the socket carried + * a status and no readable value under it — an empty 204, or a body whose whole JSON is `null`. + * It is written in the uncertainty register {@link vendorCall}'s `APIConnectionError` sentence uses + * ("is not known here") rather than the refusal register ("Arcade refused …"), because an empty + * answer is not the vendor declining: whether the call took effect is genuinely unknown, and every + * caller that spends a DELETE off this reading must treat it as unknown. + * + * NOT A SHAPE QUOTED BACK. The body is the thing this file exists to keep off an admin page; see + * {@link vendorCall}. "No body" says all there is to say without quoting one. + */ +const unreadableBody = (step: string): string => + `Arcade answered ${step} with no body this deployment could read — an empty response, or one whose JSON is the literal null — so what it says is not known here.`; + +/** + * THE ANSWER'S BODY, OR THIS DEPLOYMENT'S REFUSAL — never a dereference of `null`. + * + * FOR THE CALLS THAT ARE NOT PAGED LISTINGS. A page goes through `OffsetPage`'s constructor, which + * raises on a null body and is caught in {@link vendorCall}; `tools.authorize`, `auth.status` and + * `auth.confirmUser` go through no constructor at all, so the SDK hands a literal `null` straight + * back WEARING ITS DECLARED RESPONSE TYPE. The generated types are a description of what Arcade + * sends today and are validated by nothing — the same reading `confirmReturn`'s `auth_id` check + * already states one field down — so reading `answer.status` off one of those is the identical + * unguarded dereference, raising the identical bare `TypeError` from inside this file instead. + * + * THE FIELD-LEVEL GUARDS BELOW STAY EXACTLY AS THEY ARE: this establishes only that there is an + * object to read fields off, and says nothing about what is in it. + */ +function requireBody(step: string, answer: T): T & object { + if (answer === null || typeof answer !== "object") { + throw new Error(unreadableBody(step)); + } + return answer as T & object; +} + /** * ONE CALL TO THE VENDOR, WITH ITS REFUSAL TURNED INTO THIS DEPLOYMENT'S OWN SENTENCE. * @@ -102,11 +139,26 @@ export function normalizeArcadeError(error: unknown): ArcadeFailure | null { * any of those. `step` is written HERE, at the call site, for the reason `callTool`'s refusal names * the action from the call site: nothing in an `APIError` says what was being asked. * - * WHAT IS NOT AN SDK ERROR IS RETHROWN UNTOUCHED, which is deliberate and is load-bearing twice - * over. It leaves this file's own refusals — the malformed-listing sentences, the mint with no url - * — exactly as they were; and it leaves the vendor's `TypeError` out of the SDK's `OffsetPage` - * constructor, which a JSON body of literal `null` raises, escaping as itself. Catching THAT by - * shape rather than by `normalizeArcadeError` answering is what would swallow genuine `APIError`s. + * WHAT IS NOT AN SDK ERROR IS RETHROWN UNTOUCHED, which is deliberate: it leaves this file's own + * refusals — the malformed-listing sentences, the mint with no url — exactly as they were. + * + * WITH ONE EXCEPTION, AND IT IS THE BODY THE SDK COULD NOT READ. A 204 with `content-length: 0`, + * and a body whose JSON is the literal `null`, both reach `OffsetPage`'s constructor as `body === + * null` and raise a bare `TypeError: null is not an object (evaluating 'body.items')` from inside + * `@arcadeai/arcadejs`, before one line of this file has looked at anything. Left to escape as + * itself, that `TypeError` is the WRONG ANSWER TWICE: an operator reading the Plugins page gets a + * vendor stack trace instead of this deployment's sentence, indistinguishable in the logs from a + * programmer error here; and the only reason the revoke invariant holds on those two shapes is + * that a crash is technically "not answering" — one tidy-up of the cast that raises it (a `?? []`, + * say) turns both into `revoke` answering `false` with no DELETE spent, which is the live defect + * `broker.ts:712-717` forbids. So the body this deployment could not read gets a REFUSAL, the same + * one {@link requireBody} writes for the non-paged calls, and never a fallback to an empty list. + * + * THE ORDER IS WHAT MAKES THAT SAFE, and it is the whole of the old warning against catching "by + * shape": `normalizeArcadeError` IS ASKED FIRST and every `APIError` answers it, so nothing + * reaching the `TypeError` branch carries a vendor response at all. The `run()` closures here are + * one `client.*` call each and nothing else, so a `TypeError` with no `APIError` anywhere in its + * chain is the SDK failing to read an answer, which is exactly what the sentence says. * * A REFUSAL THAT IS ALREADY MEDIATED PASSES STRAIGHT THROUGH, so a nested call cannot dress one * sentence up in another. @@ -117,7 +169,10 @@ async function vendorCall(step: string, run: () => Promise): Promise { } catch (error) { if (error instanceof ArcadeFailureError) throw error; const failure = normalizeArcadeError(error); - if (!failure) throw error; + if (!failure) { + if (error instanceof TypeError) throw new Error(unreadableBody(step)); + throw error; + } throw new ArcadeFailureError( failure.status === undefined ? // `status` is undefined for `APIConnectionError` and `APIUserAbortError`: no response was @@ -298,6 +353,11 @@ function scopesOf(item: unknown): string[] { */ function shapeOf(value: unknown): string { if (Array.isArray(value)) return "a list"; + // `typeof null` IS `"object"`, so the bare template would write "a object" — the wrong grammar + // and, worse, the wrong fact: a hole is not a shape this deployment read and could not use, it + // is the absence of one. The callers that pass a present-but-unreadable field guard both of + // these before asking; `connectionsFor`'s unreadable ROW is the one that can carry a `null`. + if (value === null || value === undefined) return "nothing"; return `a ${typeof value}`; } @@ -633,9 +693,34 @@ async function connectionsFor( incomplete: (seen, total) => `Arcade listed ${seen} of ${total} connections, so what this account holds at ${toolkit} could not be read in full.`, })) { - const row = item as ConnectionRow; + /* + * A ROW THAT IS NOT AN OBJECT IS A HOLE IN THE LISTING, AND A HOLE IS NOT A ROW TO SKIP. + * + * `items: [null]` is what the live API sent here, and `item as ConnectionRow` believed it: the + * very next line read `row.provider_id` off `null` and raised a bare `TypeError`, which is the + * wrong answer in both directions. It carries a stack trace onto the Disconnect path where + * this deployment's own sentence belongs; and the only reason the revoke invariant held on + * this shape was that a crash is technically "not answering" — make the cast defensive with a + * skip or a `?? []` and the listing silently becomes two rows instead of three, which is + * `revoke` answering off a listing it could not read whole, the exact outcome + * `broker.ts:712-717` forbids. + * + * SO IT REFUSES, in the register of this listing's own `malformed` sentence one block up: the + * unreadable row is not attributable to any provider instance, and a DELETE spent on the rows + * BESIDE it would be a partial ask reported as a clean success. + */ + // ARRAYS ARE REFUSED WITH THE REST, which {@link record} alone would not do — it answers a + // list as an object, and a list has no `provider_id`, so the row would be dropped silently + // and the listing would again be read short. + const row = Array.isArray(item) ? null : record(item); + if (!row) { + throw new Error( + `Arcade's connection list carried ${shapeOf(item)} where a connection belongs, so what this account holds at ${toolkit} could not be read.`, + ); + } const instance = textOf(row.provider_id)?.toLowerCase(); - if (instance !== undefined && instances.has(instance)) rows.push(row); + if (instance !== undefined && instances.has(instance)) + rows.push(row as ConnectionRow); } return rows; } @@ -1052,11 +1137,17 @@ export function createArcadeClient(apiKey: string): { // puts the body it could not read a `message` off into its own `message`. See // {@link vendorCall}; the route's definite-or-uncertain decision reads `status` through // `normalizeArcadeError` and is unaffected. - const answer = await vendorCall(`the authorization for ${action}`, () => - client.tools.authorize({ - tool_name: action, - user_id: account.vendorUserId, - }), + // THROUGH {@link requireBody}: a mint that answered an empty body, or a body of literal + // `null`, has `answer === null` wearing `AuthorizationResponse`, and the `status` read below + // would raise a bare `TypeError` rather than the sentence this route shows somebody. + const answer = requireBody( + `the authorization for ${action}`, + await vendorCall(`the authorization for ${action}`, () => + client.tools.authorize({ + tool_name: action, + user_id: account.vendorUserId, + }), + ), ); // WHAT THE VENDOR SAID ABOUT THIS AUTHORIZATION, READ BEFORE ITS URL IS READ AS A NEXT STEP. // `tools.authorize` does not only answer "go to this screen": it answers `completed` for an @@ -1174,8 +1265,15 @@ export function createArcadeClient(apiKey: string): { // // AND THE CALLER'S DEADLINE REACHES THE WIRE: an abandoned poll closes its socket rather // than leaving this deployment holding one open against a vendor nobody is waiting on. - const answer = await vendorCall("an authorization status read", () => - client.auth.status({ id }, { signal: options?.signal }), + // + // AND THROUGH {@link requireBody}, for `authorize`'s reason: an empty body arrives here + // as `null` wearing the declared response type, and the three field reads below would + // raise a bare `TypeError` instead of saying what happened. + const answer = requireBody( + "an authorization status read", + await vendorCall("an authorization status read", () => + client.auth.status({ id }, { signal: options?.signal }), + ), ); const scopes = answer.scopes; return { @@ -1194,13 +1292,21 @@ export function createArcadeClient(apiKey: string): { confirmReturn: async ({ handle, vendorUserId }, options) => { let answer: { auth_id?: unknown }; try { - answer = await vendorCall("the return-leg confirmation", () => - client.auth.confirmUser( - { - flow_id: handle, - user_id: vendorUserId, - }, - { signal: options?.signal }, + // THROUGH {@link requireBody}, for `authorize`'s reason. The refusal it writes is + // raised INSIDE the try, which is deliberate and harmless: `normalizeArcadeError` + // answers `null` for it, so it is not read as `user_mismatch` and is rethrown as + // itself by the `throw error` below — an unreadable body is an outage, which is the + // same judgement the `auth_id` check further down already states. + answer = requireBody( + "the return-leg confirmation", + await vendorCall("the return-leg confirmation", () => + client.auth.confirmUser( + { + flow_id: handle, + user_id: vendorUserId, + }, + { signal: options?.signal }, + ), ), ); } catch (error) { diff --git a/server/tests/arcade-unreadable-body-engine.test.ts b/server/tests/arcade-unreadable-body-engine.test.ts new file mode 100644 index 000000000..3f8174c86 --- /dev/null +++ b/server/tests/arcade-unreadable-body-engine.test.ts @@ -0,0 +1,239 @@ +// server/tests/arcade-unreadable-body-engine.test.ts +/** + * WHAT A BODY THIS DEPLOYMENT COULD NOT READ ANSWERS WITH — THE REFUSAL, NEVER A `TypeError`. + * + * `arcade-revoke-invariant.test.ts` pins the OUTCOME on three of these shapes: a 204 with + * `content-length: 0`, a body whose JSON is the literal `null`, and `items: [null]` on the + * user-connections listing. All three satisfied it BY ACCIDENT before this file existed — the + * adapter did not refuse them, it crashed on an unguarded cast with + * `TypeError: null is not an object`, and a crash is technically "not answering". + * + * That is worth its own file for two reasons the invariant cannot state: + * + * 1. AN ACCIDENT IS ONE TIDY-UP AWAY FROM THE LIVE DEFECT. The cast that crashed is exactly the + * kind a reviewer waves a `?? []` through as defensive hygiene — and with one, all three + * shapes become `revoke` answering `false` with no DELETE spent while the caller deletes the + * local row, which is what `broker.ts:712-717` forbids and what `28b13e4a` already fixed once + * on this branch. Asserting the SENTENCE, and not merely that something was thrown, is what + * makes that tidy-up fail a test instead of passing review. + * 2. A RAW `TypeError` IS THE WRONG OPERATOR EXPERIENCE. It carries a vendor stack trace where + * this deployment's sentence belongs and is indistinguishable in the logs from a programmer + * error here. + * + * SERVED OVER A REAL SOCKET, for the invariant file's reason: these shapes ARE what the wire + * carried, and a hand-built stub cannot pose them — `OffsetPage`'s constructor is the thing that + * raises on two of the three, and a stub never passes through it. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +/** A live, attributable user-connection row: the thing a DELETE would be spent on. */ +const connectionRow = (n: number) => ({ + id: `at_${n}`, + connection_id: `ac_${n}`, + connection_status: "active", + provider_id: "google-openbot", + provider_type: "oauth2", + provider_user_info: { name: `person ${n}` }, + scopes: [], + user_id: account.vendorUserId, +}); + +const toolRow = (toolkit: string, n: number) => ({ + qualified_name: `${toolkit}.Action${n}`, + description: "", + toolkit: { name: toolkit, description: "" }, + input: { parameters: [] }, + requirements: { + authorization: { provider_id: "google", oauth2: { scopes: [] } }, + }, +}); + +/** What the connection listing answers. Everything else is the healthy live listing. */ +type Shape = + /** A 204 with `content-length: 0` on a PAGED endpoint. */ + | "listing-is-204" + /** A 200 whose whole JSON value is `null`. */ + | "listing-is-json-null" + /** A list with a hole in it: one unreadable row beside two real connections. */ + | "listing-item-is-null"; + +type Engine = { port: number; requests: string[]; stop: () => void }; + +function startEngine(shape: Shape): Engine { + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}`); + const offset = Number(url.searchParams.get("offset") ?? "0"); + const limit = Number(url.searchParams.get("limit") ?? "100"); + + // THE DELETE, always available and always counted, so "no DELETE was spent" is a statement + // about the adapter rather than about the stub. + if (url.pathname.startsWith("/v1/admin/user_connections/")) { + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/v1/admin/user_connections") { + if (shape === "listing-is-204") + return new Response(null, { status: 204 }); + if (shape === "listing-is-json-null") { + return new Response("null", { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return Response.json({ + items: [null, connectionRow(0), connectionRow(1)], + offset, + limit, + page_count: 3, + total_count: 3, + }); + } + + if (url.pathname === "/v1/admin/auth_providers") { + return Response.json({ + items: [ + { id: "google-openbot", provider_id: "google", type: "oauth2" }, + ], + offset: 0, + limit, + page_count: 1, + total_count: 1, + }); + } + + if (url.pathname === "/v1/tools") { + const toolkit = url.searchParams.get("toolkit") ?? "Gmail"; + const page = + offset === 0 + ? Array.from({ length: 4 }, (_, n) => toolRow(toolkit, n)) + : []; + return Response.json({ + items: page, + offset, + limit, + page_count: page.length, + total_count: 4, + }); + } + + return new Response("not found", { status: 404 }); + }, + }); + const { port } = server; + if (port === undefined) + throw new Error("the stub vendor did not bind a port"); + return { port, requests, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(shape: Shape): Engine { + running = startEngine(shape); + // `new Arcade({ apiKey })` reads this at construction, so the server starts FIRST. + process.env.ARCADE_BASE_URL = `http://localhost:${running.port}`; + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +type Thrown = { name: string; message: string }; + +async function revokeError(): Promise { + const { broker } = createArcadeClient("never-dialled"); + try { + const value = await broker.revoke({ account, toolkit: "Gmail" }); + // An ANSWER is the failure this file exists to catch, so it is reported as one rather than + // left to a later assertion on an error that is not there. + return { name: "returned", message: `revoke answered ${String(value)}` }; + } catch (error) { + return { + name: error instanceof Error ? error.constructor.name : "non-error", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * The refusal sentence each shape is owed, VERBATIM. Written out rather than imported, because an + * assertion that re-derives the sentence from the code under test asserts nothing about it. + */ +const EMPTY_BODY_REFUSAL = + "Arcade answered the connection listing for Gmail with no body this deployment could read — an empty response, or one whose JSON is the literal null — so what it says is not known here."; +const HOLE_IN_LISTING_REFUSAL = + "Arcade's connection list carried nothing where a connection belongs, so what this account holds at Gmail could not be read."; + +describe("a vendor body this deployment cannot read answers with a refusal", () => { + const cases: [Shape, string, string][] = [ + [ + "listing-is-204", + "a 204 / content-length: 0 response on a paged endpoint", + EMPTY_BODY_REFUSAL, + ], + [ + "listing-is-json-null", + "a body whose JSON is the literal null", + EMPTY_BODY_REFUSAL, + ], + [ + "listing-item-is-null", + "items: [null] on the user-connections listing", + HOLE_IN_LISTING_REFUSAL, + ], + ]; + + for (const [shape, sentence, refusal] of cases) { + test(`${sentence} refuses, and says so`, async () => { + const e = engine(shape); + const thrown = await revokeError(); + + // THE SENTENCE AND NOT MERELY "IT THREW". Before this fix every one of these three raised + // `TypeError: null is not an object`, which passes any "did it throw" assertion while + // telling an operator nothing and leaving the invariant resting on a crash. + expect({ shape, ...thrown }).toEqual({ + shape, + name: "Error", + message: refusal, + }); + + // AND NOTHING WAS WITHDRAWN ON THE WAY TO SAYING IT: a listing that could not be read whole + // is not grounds for spending a DELETE on the rows that happened to be legible. + expect(e.requests.filter((r) => r.startsWith("DELETE "))).toEqual([]); + }); + } + + test("a TypeError is never what escapes", async () => { + for (const shape of [ + "listing-is-204", + "listing-is-json-null", + "listing-item-is-null", + ] as Shape[]) { + engine(shape); + const thrown = await revokeError(); + expect({ shape, name: thrown.name }).toEqual({ shape, name: "Error" }); + expect(thrown.message).not.toContain("is not an object"); + running?.stop(); + running = null; + useArcadeClient(null); + } + }); +}); From af911edb83af250d45df23cb190562be25d57aad Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:42:26 +0200 Subject: [PATCH 169/232] test(arcade): cover connectionIsLive in the file its three paths live in A7. No fixture in `arcade-adapter-connections.test.ts` carried `connection_status`, so `connectionIsLive` was untested through all three lifecycle members in the very file those members are specified in. A fixture set of live-only rows cannot tell the two halves of the design apart: `isConnected`/`accountName` must SKIP a dead row, and `revoke` must STILL DELETE it, because a dead row is a row the vendor still holds and answering `false` with no DELETE spent is the live defect signature this seam already shipped once. Adds `statusRow` (a `null` status OMITS the key, which is the shape an engine that does not send the field answers with) and four tests: - a dead row alone: isConnected false, accountName null, revoke TRUE + ac_1 deleted - a dead row FIRST beside a live one: the live one answers, both deleted - absent and blank status are SILENCE and still read as live - the status is folded and trimmed (` ACTIVE ` live, `Expired` not) Test-only. `server/src/` unchanged; `arcade-revoke-invariant.test.ts` untouched (still 7 pass / 0 fail). Appended at the end of the file, no existing line edited, to stay clear of the sibling agent editing `listing()`. No call sites changed: `statusRow` is new and local to this file. Watched failing against planted violations in `connectionIsLive`: - `return true` (guard deleted) -> 3 fail: isConnected expected false got true; accountName expected "Grace" got "Ada"; `Expired` expected false got true. - `revoke` filtering on `connectionIsLive` -> 2 fail: revoke expected true got false; deleted expected ["ac_1","ac_2"] got ["ac_2"]. - silence inverted (`return false` for absent/empty) -> 6 fail, including the new silence test (expected true got false) and five pre-existing tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/arcade-adapter-connections.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index 979e4df99..77e9b8295 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -493,3 +493,108 @@ describe("arcade adapter connection lifecycle", () => { expect(read).toHaveLength(1); }); }); + +/** + * The same row, carrying the vendor's OWN answer to "is this connection still usable". + * + * Separate from {@link row} rather than a fourth parameter on it, because the point is that the + * field is OPTIONAL on the wire: every fixture above omits it on purpose, and omission must keep + * meaning "the vendor said nothing". A `null` status omits the key entirely, which is the shape an + * engine that does not send the field answers with. + */ +const statusRow = ( + n: number, + instance: string, + status: string | null, + info?: unknown, +) => ({ + ...row(n, instance, info), + ...(status === null ? {} : { connection_status: status }), +}); + +/** + * THE ROW'S EXISTENCE IS NOT THE ANSWER, AND THE THREE MEMBERS DO NOT AGREE ABOUT WHAT IS. + * + * `connection_status` is Arcade saying whether a connection still works. The positive reads — + * `isConnected`, `accountName` — must SKIP a row it reports as expired, revoked, pending or failed, + * because a screen saying "Connected, as Grace" off such a row is wrong in the one place somebody + * looks to check, and nothing contradicts it until a tool call fails far away from here. + * + * `revoke` must STILL DELETE that row, which is the half a fixture set of live-only rows cannot + * tell apart from the other. A dead row is a row the VENDOR still holds; skipping it would have + * `revoke` answer `false` — "there was nothing to withdraw" — while `store.ts`'s disconnect path + * deletes the local `brokered_connections` row on that answer regardless, leaving a grant at the + * vendor that nothing this deployment holds any longer names. That is the exact signature of the + * live defect this seam has already shipped once. + * + * Every fixture here is under `cpk-poc`/`google-openbot`, INSTANCE ids this project's + * configuration names, so nothing below can pass or fail for the instance-vs-type reason the rest + * of this file is about: the only variable is the status. + */ +describe("a connection the vendor reports as dead", () => { + beforeEach(() => { + providers(TOOLKIT_TYPES); + configs(); + }); + + test("is skipped by isConnected and accountName, and is STILL DELETED by revoke", async () => { + listing([statusRow(1, "cpk-poc", "expired", { name: "Ada" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Github"; + expect(await broker.isConnected({ account, toolkit })).toBe(false); + expect(await broker.accountName({ account, toolkit })).toBeNull(); + // Not `false`-with-no-delete: the row is the vendor's, and Disconnect must spend the call. + expect(await broker.revoke({ account, toolkit })).toBe(true); + expect(deleted).toEqual(["ac_1"]); + }); + + test("beside a live row, the LIVE one answers and revoke deletes both", async () => { + listing([ + statusRow(1, "cpk-poc", "expired", { name: "Ada" }), + statusRow(2, "cpk-poc", "active", { name: "Grace" }), + ]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Github"; + expect(await broker.isConnected({ account, toolkit })).toBe(true); + // The dead row is FIRST, so a member that stopped at the first row would say "Ada". + expect(await broker.accountName({ account, toolkit })).toBe("Grace"); + expect(await broker.revoke({ account, toolkit })).toBe(true); + expect([...deleted].sort()).toEqual(["ac_1", "ac_2"]); + }); + + test("a status the vendor did not send, or sent blank, is SILENCE and still reads as live", async () => { + listing([ + statusRow(1, "cpk-poc", null, { name: "Ada" }), + statusRow(2, "google-openbot", "", { name: "Grace" }), + ]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Github" })).toBe(true); + expect(await broker.accountName({ account, toolkit: "Github" })).toBe( + "Ada", + ); + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe( + "Grace", + ); + }); + + test("the status is folded and trimmed, so ` ACTIVE ` is live and `Expired` is not", async () => { + listing([statusRow(1, "cpk-poc", " ACTIVE ", { name: "Ada" })]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Github" })).toBe(true); + listing([statusRow(2, "cpk-poc", "Expired", { name: "Ada" })]); + expect(await broker.isConnected({ account, toolkit: "Github" })).toBe( + false, + ); + }); +}); From 5dbb8778e36586b023df0b29b0d272c5883b872a Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:43:15 +0200 Subject: [PATCH 170/232] test(arcade): pin the connection listing to the vendor identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listing()` in `arcade-adapter-connections.test.ts` served its rows to every query, ignoring the `user_id` the adapter sends. Nothing in the repo therefore pinned that `connectionsFor` reads the listing for `account.vendorUserId`: sending `account.userId` instead, or sending no `user_id` at all, passed all of that file's tests. Scoping that read to the vendor identity is the whole subject of commit 28b13e4a, a live defect in which one person's Disconnect could read — and spend DELETEs against — another person's connections. The stub now answers as the real `admin.userConnections.list` does: rows only to a query naming the identity that holds them, an empty page to any other. Two tests are added: one asserting the identity by name on every page and across all three lifecycle members, one proving connections held by another vendor identity are neither read nor deleted. Watched failing, twice, against planted production defects: - `connectionsFor(client, account.userId, toolkit)` at the three call sites -> 10 fail / 14 pass - `user_id` omitted from the `admin.userConnections.list` query -> 10 fail / 14 pass Restored in both cases -> 24 pass / 0 fail. `server/src/` is unchanged. Call sites of the changed helper: `listing()` is file-local to `server/tests/arcade-adapter-connections.test.ts`; all 23 of its calls are in that file. The new second parameter is optional and defaults to `account.vendorUserId`, so no existing call changed. The identically named helpers in `arcade-adapter-status.test.ts` and `composio-transport.test.ts` are unrelated file-local functions. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/arcade-adapter-connections.test.ts | 69 +++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index 979e4df99..d5147d61d 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -135,19 +135,36 @@ function configs(items: unknown[] = CONFIGS) { * engine omits `total_count` — see `arcade-pagination-engine.test.ts`, which proves that against a * real HTTP engine. So the stub answers what the real `list` resolves to: an object with `items`, * honouring the `limit`/`offset` the adapter sends. + * + * AND SCOPED TO THE IDENTITY THE QUERY NAMES, which is the other half of what the real listing + * does and what this stub once left out. `admin.userConnections.list` answers the connections of + * ONE `user_id`; a stub that served its rows to every query made the identity the adapter sends + * unobservable, so sending `account.userId` — the LOCAL id — or sending no `user_id` at all passed + * every test in this file. Scoping the read to `account.vendorUserId` is the whole subject of + * commit `28b13e4a`, a live defect in which one person's Disconnect could read, and spend DELETEs + * against, another person's connections. So: rows are served only to a query naming `heldBy` + * (the vendor identity by default), and any other query — wrong id, or none — gets a real empty + * page, exactly as the vendor would answer for an identity holding nothing. + * + * Returns the queries it was asked, so a test can assert the identity by name as well as by effect. */ -function listing(rows: unknown[]) { +type ListingQuery = { user_id?: unknown; limit?: number; offset?: number }; +function listing(rows: unknown[], heldBy: string = account.vendorUserId) { + const asked: ListingQuery[] = []; stub("list", (query) => { - const asked = query as { limit?: number; offset?: number } | undefined; - const offset = asked?.offset ?? 0; - const limit = asked?.limit ?? rows.length; + const q = (query ?? {}) as ListingQuery; + asked.push(q); + const mine = q.user_id === heldBy ? rows : []; + const offset = q.offset ?? 0; + const limit = q.limit ?? mine.length; return Promise.resolve({ - items: rows.slice(offset, offset + limit), + items: mine.slice(offset, offset + limit), offset, limit, - total_count: rows.length, + total_count: mine.length, }); }); + return asked; } /** The TYPE each toolkit's listing names. Nothing here is an instance id. */ @@ -183,6 +200,46 @@ describe("arcade adapter connection lifecycle", () => { ); }); + /* + * THE IDENTITY THE LISTING IS READ FOR, ASSERTED BY NAME. `AccountRef` carries two ids and they + * are not interchangeable: `userId` is this deployment's own, `vendorUserId` is the one Arcade + * files a connection under. Reading the listing for the wrong one is what commit `28b13e4a` + * fixed in the source, and sending no `user_id` at all is worse still — `admin.userConnections` + * then answers EVERY account's connections, which `revoke` would attribute to this one and + * spend DELETEs against. Both are spelled out here because an effect-only assertion cannot tell + * the difference between "scoped to the right id" and "scoped to a wrong id that happens to + * hold these rows". + */ + test("the connection listing is read for the VENDOR identity, on every page and every member", async () => { + const asked = listing([row(1, "cpk-poc", { name: "Ada" })]); + stub("delete", () => Promise.resolve()); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Github"; + expect(await broker.isConnected({ account, toolkit })).toBe(true); + expect(await broker.accountName({ account, toolkit })).toBe("Ada"); + expect(await broker.revoke({ account, toolkit })).toBe(true); + expect(asked.length).toBeGreaterThan(0); + for (const query of asked) { + expect(query.user_id).toBe(account.vendorUserId); + expect(query.user_id).not.toBe(account.userId); + } + }); + + test("connections held by ANOTHER vendor identity are neither read nor deleted", async () => { + listing([row(1, "cpk-poc", { name: "Ada" })], "vendor-somebody-else"); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Github"; + expect(await broker.isConnected({ account, toolkit })).toBe(false); + expect(await broker.accountName({ account, toolkit })).toBeNull(); + expect(await broker.revoke({ account, toolkit })).toBe(false); + expect(deleted).toEqual([]); + }); + test("delete is passed the ac_ connection_id, never the at_ id", async () => { listing([row(7, "cpk-poc")]); const deleted: unknown[] = []; From 36a9625ae654bda1178bab907c3677a50483a8a4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:44:18 +0200 Subject: [PATCH 171/232] fix(plugins): record who consented on an Arcade-completed Shared connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `completeAuthorizationAttempt` wrote every column of `brokered_connections` except `connected_by`, so an Arcade-consented Shared (deployment-held) app's row was permanently NULL and `deploymentConnectionFor` answered that nobody connected the team account. `input.actorId` — the session subject (`users.id`) the attempt is keyed on, and the id the admin read left-joins `users` on — was already in hand. Now written on the insert for a deployment-held account (null for a person's row, which `user_id` already attributes), and on the deployment conflict branch too, so a second administrator re-consenting supersedes the first — the rule `recordBrokeredConnection` already states for its own upsert. Readers were checked and left alone: a historical NULL stays NULL through `deploymentConnectionFor` (`connectedByEmail ?? connectedBy`) and `sharedConnections`, and `admin/plugins/$key.tsx` omits the "by …" clause entirely rather than rendering a blank name. Lines touched in server/src/plugins/store.ts: 6634-6653 (the new `connectedBy` local, the insert value) and 6683 (the deployment conflict `set`). No other writer of this table drops attribution: `recordBrokeredConnection` (the only other `insert`/`update` of `brokered_connections`) already takes and writes `connectedBy`, and its `only: "a row that is still there"` update leaves the column standing by design. No signature changed, so call sites are unchanged. The one production caller is server/src/plugins/routes.ts:3393 (the Arcade return-leg handler), which already passed `actorId`; the test caller is server/tests/arcade-completion-transaction.integration.test.ts. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 21 +++++- ...completion-transaction.integration.test.ts | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e165d753a..23f578217 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -6631,6 +6631,24 @@ export function createPluginStore(options: PluginStoreOptions) { probeAction: null, updatedAt: verifiedAt, }; + /* + * WHO CONNECTED THE TEAM ACCOUNT, WRITTEN HERE FOR THE SAME REASON EVERY OTHER WRITER + * WRITES IT. `connectedBy` is only ever a deployment-held row's column — a person's row is + * attributed by `user_id` already — and for a Shared app it is the attribution an + * administrator reads off `deploymentConnectionFor` to decide whether a team connection is + * legitimate and who to ask about it. This writer held `actorId`, the session subject + * (`users.id`) the attempt is keyed on and the id the admin read joins `users` on, and + * recorded none of it: an Arcade-consented Shared app's row was permanently null, and the + * page then said nobody connected it. The null is a real state for rows written before + * this, which is why both readers keep answering `null` rather than inventing a name. + * + * AND THE RE-CONSENT SUPERSEDES, which is `recordBrokeredConnection`'s rule spelled the + * same way: a deployment account is shared, so the administrator who most recently + * completed a consent for it is the one the row credits. Unconditional here, unlike there, + * because this path always holds an actor — there is no caller that passes none. + */ + const connectedBy = + input.account.holder === "deployment" ? input.actorId : null; await transaction .insert(brokeredConnections) .values({ @@ -6642,6 +6660,7 @@ export function createPluginStore(options: PluginStoreOptions) { userId: input.account.holder === "person" ? input.account.userId : null, vendorUserId: input.account.vendorUserId, + connectedBy, ...set, }) .onConflictDoUpdate( @@ -6661,7 +6680,7 @@ export function createPluginStore(options: PluginStoreOptions) { brokeredConnections.app, ], targetWhere: sql`${brokeredConnections.holder} = 'deployment'`, - set, + set: { ...set, connectedBy }, }, ); diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index cea1d5d30..f556f0d29 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -214,6 +214,11 @@ describe("completeAuthorizationAttempt", () => { expect(connections[0]?.verified).toBe(true); expect(connections[0]?.verifiedAt).not.toBeNull(); expect(connections[0]?.probeAction).toBeNull(); + // A PERSON'S ROW IS ATTRIBUTED BY `user_id` AND NEVER BY `connected_by`, which is the + // deployment-held column: `deploymentConnectionFor` is the only reader and it reads only + // `holder = 'deployment'`. Writing the actor here would put an id in a column no reader of a + // person's row consults, on the row that already names them. + expect(connections[0]?.connectedBy).toBeNull(); const events = await connectedEventsFor(app); expect(events.length).toBe(1); @@ -230,6 +235,69 @@ describe("completeAuthorizationAttempt", () => { expect(attempt.completedAt).not.toBeNull(); }); + /* + * WHO CONNECTED THE TEAM ACCOUNT. A Shared app's row is the one place an administrator can read + * that from, and a consent completed through Arcade used to leave it permanently null — so + * `deploymentConnectionFor` answered that nobody connected an account the whole deployment acts + * as. The actor is the session subject (`users.id`) the attempt is keyed on, which is the id the + * admin read joins `users` on for an email. + */ + test("a DEPLOYMENT-held completion records the administrator who consented, and a later one supersedes", async () => { + const first = actor("shared-first"); + const second = actor("shared-second"); + const app = toolkit("shared"); + const deployment: AccountRef = { + holder: "deployment", + vendorUserId: `v-${app}`, + }; + + const claimFor = async (actorId: string, authId: string) => { + const claimed = await store.claimAuthorizationAttempt({ + actorId, + serverId, + action: "Google.SendEmail", + account: deployment, + }); + if (!claimed.claimed) throw new Error("claim failed"); + const minted = await store.recordAuthorizationMint({ + actorId, + createdAt: claimed.attempt.createdAt, + authId, + }); + if (!minted) throw new Error("mint failed"); + }; + const complete = (actorId: string, authId: string) => + store.completeAuthorizationAttempt({ + actorId, + authId, + toolkit: app, + account: deployment, + action: "Google.SendEmail", + scopes: ["gmail.send"], + authProvider: "google", + }); + + await claimFor(first, "ar_shared_first"); + await complete(first, "ar_shared_first"); + + let connections = await connectionsFor(app); + expect(connections.length).toBe(1); + expect(connections[0]?.holder).toBe("deployment"); + expect(connections[0]?.userId).toBeNull(); + expect(connections[0]?.connectedBy).toBe(first); + + // AND THE SECOND ADMINISTRATOR SUPERSEDES THE FIRST, because a deployment account is shared + // and the row credits whoever most recently consented for it. The insert takes the conflict + // branch here, which is the half that used to keep an attribution standing while rewriting + // everything around it. + await claimFor(second, "ar_shared_second"); + await complete(second, "ar_shared_second"); + + connections = await connectionsFor(app); + expect(connections.length).toBe(1); + expect(connections[0]?.connectedBy).toBe(second); + }); + test("two CONCURRENT calls write exactly one connection and one audit event, and the loser answers as the winner would", async () => { const a = actor("race"); const app = toolkit("race"); From faca7c73d6957fb4ad07c08f87b7074a32d73829 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:44:24 +0200 Subject: [PATCH 172/232] Hold the completed_at/outcome pairing in the database, not in a comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `0058` documented the pairing of `pending_authorizations.completed_at` and `outcome` and enforced neither direction. A row carrying an `outcome` with `completed_at` still NULL is a FINISHED attempt that every reader reads as a LIVE one: `claimAuthorizationAttempt` frees a held key only `WHERE created_at < now() - interval '15 minutes' OR completed_at IS NOT NULL`, and `authorizationAttemptFor` answers on `completed_at IS NULL` alone — neither consults `outcome`. One such row therefore locks its actor out of starting any authorization for the rest of the 15-minute TTL, with a verdict already recorded against it. The mirror shape, a `completed_at` with no `outcome`, is an attempt that ended with nothing saying how, and `outcome` is what every operator-facing reading of this table is of. WHY A CHECK AND NOT AN APPLICATION GUARD. The pairing is readable as an application invariant, and today the application already keeps it: all three writers set both columns in ONE statement — the completion `UPDATE` in `completeArcadeAuthorization`, `closeAuthorizationAttempt` behind `failAuthorizationAttempt`/`markAuthorizationUnreconciled`/ `markAuthorizationWithdrawn`, and the `ON CONFLICT DO UPDATE` in `claimAuthorizationAttempt`, which resets both to NULL. That they agree today is exactly what makes the invariant invisible to the next writer, to a hand-run UPDATE and to a partial restore, none of which pass through those three functions. An application-side guard would also have to be spelled in each of them and could drift; the CHECK is spelled once and cannot. It is written as `(completed_at IS NULL) = (outcome IS NULL)` rather than two OR-ed clauses because that expression is never itself NULL, so the CHECK has no third, silently-accepting verdict. A NEW MIGRATION, `0059`, NOT AN EDIT OF `0058`, which has shipped on this branch and been applied. `generate` emitted exactly one `ALTER TABLE ... ADD CONSTRAINT` and appended one journal entry; no existing migration or snapshot was renumbered or rewritten (`0059_snapshot.json` differs from `0058`'s by that one `checkConstraints` member alone). No backfill is needed and none is included: `pending_authorizations` is created by `0057` on this same unmerged branch, so no deployment holds a row predating the pairing, and every row any deployment can have was written by the three paired writers above. NOT TOUCHED: `closeAuthorizationAttempt`'s unconditional re-stamp of an already-completed attempt (agent B2's finding this wave) and `server/tests/arcade-revoke-invariant.test.ts`. The constraint is compatible with either outcome of B2: it constrains the PAIR a write leaves behind, not which writes may happen. CALL SITES. No signature changed. Writers of the two columns, all already paired and all re-run green: `store.ts` completion UPDATE, `closeAuthorization- Attempt`, `claimAuthorizationAttempt`. TESTS. `arcade-attempt-constraints.test.ts` gains three cases — the outcome- without-completion insert, the completion-without-outcome insert, and breaking the pair by UPDATE. Red against the pre-migration database: all three `Received: "accepted"`. The two pre-existing roster cases now set `completed_at` alongside their junk `outcome` so the row is legal in every respect but the one under test; without it postgres names whichever of the two constraints it evaluated first and the case stops being about the roster. "the legal shapes are all still accepted" pairs each ended shape for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- ..._pending_authorization_completion_pair.sql | 1 + server/drizzle/meta/0059_snapshot.json | 9964 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 24 + .../tests/arcade-attempt-constraints.test.ts | 65 +- 5 files changed, 10056 insertions(+), 5 deletions(-) create mode 100644 server/drizzle/0059_pending_authorization_completion_pair.sql create mode 100644 server/drizzle/meta/0059_snapshot.json diff --git a/server/drizzle/0059_pending_authorization_completion_pair.sql b/server/drizzle/0059_pending_authorization_completion_pair.sql new file mode 100644 index 000000000..54e7e59d8 --- /dev/null +++ b/server/drizzle/0059_pending_authorization_completion_pair.sql @@ -0,0 +1 @@ +ALTER TABLE "pending_authorizations" ADD CONSTRAINT "pending_authorizations_completion_check" CHECK (("pending_authorizations"."completed_at" IS NULL) = ("pending_authorizations"."outcome" IS NULL)); \ No newline at end of file diff --git a/server/drizzle/meta/0059_snapshot.json b/server/drizzle/meta/0059_snapshot.json new file mode 100644 index 000000000..9f947ad31 --- /dev/null +++ b/server/drizzle/meta/0059_snapshot.json @@ -0,0 +1,9964 @@ +{ + "id": "a1b20dec-1a95-4bb3-b4b1-d1196a22a6cf", + "prevId": "d7748e18-de30-4092-afcd-21350f0829a1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "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": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "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": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_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": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_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": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 579c04461..ff2585bf0 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -414,6 +414,13 @@ "when": 1791924168752, "tag": "0058_pending_authorization_checks", "breakpoints": true + }, + { + "idx": 59, + "version": "7", + "when": 1791942141206, + "tag": "0059_pending_authorization_completion_pair", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index f115d5992..f6df19888 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -500,6 +500,30 @@ export const pendingAuthorizations = pgTable( "pending_authorizations_outcome_check", sql`${table.outcome} IS NULL OR ${table.outcome} IN ('completed', 'failed', 'unreconciled', 'withdrawn')`, ), + /** + * An attempt has ended, or it has not: `completed_at` and `outcome` are set together or + * neither is. + * + * A ROW CARRYING AN `outcome` WITH `completed_at` STILL NULL IS A FINISHED ATTEMPT THAT EVERY + * READER READS AS A LIVE ONE. `claimAuthorizationAttempt` frees a held key only + * `WHERE created_at < now() - interval '15 minutes' OR completed_at IS NOT NULL`, and + * `authorizationAttemptFor` answers on `completed_at IS NULL` alone; neither consults + * `outcome`. So one such row locks its actor out of authorizing anything for the rest of the + * 15-minute TTL, with a verdict already recorded against it. The mirror shape — a + * `completed_at` with no `outcome` — is an attempt that ended with nothing saying how, and + * `outcome` is what every operator-facing reading of this table is of. + * + * HERE AND NOT IN THE APPLICATION, although both writers already set the pair in one + * statement. That those two agree today is precisely what makes the invariant invisible to the + * next writer, to a hand-run UPDATE and to a partial restore, and none of those three passes + * through the two functions that currently get it right. Written as a boolean equality rather + * than two `OR`-ed clauses because `(x IS NULL) = (y IS NULL)` is never itself NULL, so the + * CHECK can only pass or refuse — it has no third, silently-accepting verdict. + */ + check( + "pending_authorizations_completion_check", + sql`(${table.completedAt} IS NULL) = (${table.outcome} IS NULL)`, + ), ], ); diff --git a/server/tests/arcade-attempt-constraints.test.ts b/server/tests/arcade-attempt-constraints.test.ts index 69496f921..9da8b2db4 100644 --- a/server/tests/arcade-attempt-constraints.test.ts +++ b/server/tests/arcade-attempt-constraints.test.ts @@ -75,10 +75,18 @@ describe("pending_authorizations holds its declared unions", () => { ); }); + /* + * Each roster case carries a `completed_at` alongside its junk `outcome`, so the row is legal in + * every respect but the one under test. Without it the row also breaks the completion pairing + * below, postgres names whichever constraint it evaluated first, and the test stops being about + * the roster at all. + */ test("an outcome outside the roster is refused on insert", async () => { - expect(await refusedBy(() => insert({ outcome: "banana" }))).toBe( - "pending_authorizations_outcome_check", - ); + expect( + await refusedBy(() => + insert({ outcome: "banana", completedAt: new Date() }), + ), + ).toBe("pending_authorizations_outcome_check"); }); test("an outcome outside the roster is refused on update", async () => { @@ -88,12 +96,53 @@ describe("pending_authorizations holds its declared unions", () => { database .update(pendingAuthorizations) // biome-ignore lint/suspicious/noExplicitAny: a cast past the union is exactly what the CHECK is for. - .set({ outcome: "cancelled" as any }) + .set({ outcome: "cancelled" as any, completedAt: new Date() }) .where(sql`actor_id = ${actorId}`), ); expect(refusal).toBe("pending_authorizations_outcome_check"); }); + /** + * The pairing of `completed_at` and `outcome`, which is the one invariant of this table that no + * single column can hold. + * + * A row carrying an `outcome` with `completed_at` still NULL is a FINISHED attempt that every + * reader here reads as a LIVE one: `claimAuthorizationAttempt` replaces a held key only + * `WHERE created_at < now() - interval '15 minutes' OR completed_at IS NOT NULL`, and + * `authorizationAttemptFor` answers on `completed_at IS NULL` alone. Neither looks at `outcome`, + * so such a row locks its actor out of starting another authorization for the rest of the TTL, + * with a verdict already recorded against it. The other half is less damaging and just as wrong: + * a `completed_at` with no `outcome` is an attempt that ended with nothing saying how, and every + * operator-facing reading of the table is of `outcome`. + * + * Nothing in the application writes either shape — both writers set the pair in one statement — + * which is exactly why it belongs in the database rather than in a comment: the only ways in are + * a hand-run UPDATE, a restore, or the next writer, and none of those is reviewed here. + */ + test("an outcome with no completion time is refused", async () => { + expect( + await refusedBy(() => insert({ outcome: "failed", completedAt: null })), + ).toBe("pending_authorizations_completion_check"); + }); + + test("a completion time with no outcome is refused", async () => { + expect( + await refusedBy(() => insert({ completedAt: new Date(), outcome: null })), + ).toBe("pending_authorizations_completion_check"); + }); + + test("breaking the pair by UPDATE is refused too", async () => { + const actorId = `pair-${suite}`; + await insert({ actorId }); + const refusal = await refusedBy(() => + database + .update(pendingAuthorizations) + .set({ outcome: "withdrawn" }) + .where(sql`actor_id = ${actorId}`), + ); + expect(refusal).toBe("pending_authorizations_completion_check"); + }); + test("a person attempt with no user id is refused", async () => { expect( await refusedBy(() => insert({ holder: "person", userId: null })), @@ -133,6 +182,8 @@ describe("pending_authorizations holds its declared unions", () => { const action = `legal-${suite}`; await insert({ action, holder: "person", userId: `user-${suite}` }); await insert({ action, holder: "deployment", userId: null }); + // Each ended shape carries its `completed_at`, because the pair is now held by a CHECK: an + // outcome on its own is no longer one of the legal shapes, and this list says which are. for (const outcome of [ null, "completed", @@ -140,7 +191,11 @@ describe("pending_authorizations holds its declared unions", () => { "unreconciled", "withdrawn", ]) { - await insert({ action, outcome }); + await insert({ + action, + outcome, + completedAt: outcome === null ? null : new Date(), + }); } const rows = await database.execute( sql`select count(*)::int as n from pending_authorizations where action = ${action}`, From 4887aacee3b6b9167a8c575b52c3b0b6b5a28ae3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:46:20 +0200 Subject: [PATCH 173/232] fix(plugins): resolve the attempt before the app on authorize-cancel (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /servers/:id/authorize called `brokeredAccountFor` first and read NOTHING off its success branch — not the toolkit, not the account, not the title. The deletion is keyed `(actor_id, created_at)` off the attempt row itself. So the resolution bought no fact the route uses, and cost the case that most needs cancelling: an attempt whose app has stopped resolving. The person then held a live pending authorization they could not retract, and because the attempt key is the actor ALONE, that one row also refused them a new authorization on ANY app for the full fifteen minutes. The attempt is now resolved first; when it is this actor's and names this app, it is discarded without the app being resolved at all. The refusals survive on the branch where they still mean something — nothing of this actor's to cancel — so a DELETE aimed at a row that is not brokered is still told so rather than flattened into `{ cancelled: false }`. Reachable instances fixed (both exercised): - the app's url is re-pointed away from `arcade://`: `appOfUrl` answers null, 400. - an administrator who minted a Shared app's attempt is demoted mid-consent: 403. Not an instance, and now written down in the test: a hard `removeServer` is NOT one. `pending_authorizations.server_id` is `references(mcpServers.id, onDelete: "cascade")`, so deleting the server row takes the attempt with it and leaves nothing to cancel. Call sites: `brokeredAccountFor`'s signature and behaviour are unchanged; only this one route's call ORDER moved. Its other eight callers (routes.ts lines 1570, 2357, 2423, 2514, 2587, 2649, 2723 and the /arcade/verify freshness re-resolve at 3292) are untouched — and 3292 already treats a refusal as `context-changed` rather than a dead end. Red-green against the base routes.ts (076677e8), new tests unchanged: "url no longer parses as brokered" → 400, expected <300 "administrator demoted mid-consent" → 403, expected <300 Planted violation (the second branch's refusal deleted): the preservation test went red with "Expected: 400 / Received: 200". Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 43 +++++++-- server/tests/arcade-authorize-cancel.test.ts | 96 +++++++++++++++++++- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 8871afbe4..ac87697c0 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2984,6 +2984,29 @@ export function createPluginRoutes( * AND NOT ADMIN-GATED AT THE ROUTE, as the disconnect route above: `brokeredAccountFor` is where * a Shared app is refused to anybody but an administrator. * + * THE ATTEMPT IS RESOLVED BEFORE THE APP IS, AND THAT ORDER IS THE WHOLE POINT. Cancelling is an + * operation on the ATTEMPT ROW, and this route reads NOTHING off `brokeredAccountFor`'s success + * branch — not the toolkit, not the account, not the title. The deletion is keyed + * `(actor_id, created_at)` off the row itself. So resolving the app first bought no fact this + * route uses and cost the one case that needs cancelling most: AN ATTEMPT OUTLIVES ITS APP. Once + * an administrator removes the row, `brokeredAccountFor` answers `appOfUrl(null)` → "That app is + * not reached through a broker", 400, and a person holding a live pending authorization on an app + * that is gone could never retract it — while `claimAuthorizationAttempt` went on refusing them a + * new one for the full fifteen minutes. A not-found about the app, in answer to a request about + * their own attempt. + * + * DROPPING THE GATE FOR THAT CASE GIVES NOBODY ANYTHING THEY DID NOT ALREADY HAVE. The row + * reached here is the signed-in person's OWN attempt, by the only key the table has, and the act + * is its deletion; the administrator requirement on a Shared app guards that app's deployment + * account, which this route never touches. The one actor it newly admits is somebody whose + * authority changed while their own consent was in flight — and letting them retract what they + * started is the right answer, not a hole. + * + * THE REFUSALS SURVIVE WHERE THEY STILL MEAN SOMETHING, which is the branch with nothing of this + * actor's to cancel. A DELETE aimed at an ordinary OAuth row, or at no row at all, is then still + * told so in the words every sibling route uses, rather than being flattened into a cheerful + * `{ cancelled: false }` that claims the request was understood. + * * AND IT CANCELS THE ATTEMPT THE PATH NAMES, NEVER MERELY "WHATEVER THIS ACTOR IS HOLDING". The * attempt key is the actor alone, so the lookup can only answer that one row — but the request * names an app, and an attempt standing on a DIFFERENT app is not the thing being cancelled. @@ -3000,6 +3023,15 @@ export function createPluginRoutes( */ routes.delete("/servers/:id/authorize", requireUser, async (context) => { const serverId = context.req.param("id"); + const actorId = context.var.actor.id; + const attempt = await store.authorizationAttemptFor(actorId); + if (attempt && attempt.serverId === serverId) { + const cancelled = await store.discardAuthorizationAttempt({ + actorId, + createdAt: attempt.createdAt, + }); + return context.json({ cancelled }); + } const resolved = await brokeredAccountFor(context, serverId); if (resolved.refusal) { return context.json( @@ -3007,16 +3039,7 @@ export function createPluginRoutes( resolved.refusal.status, ); } - const actorId = context.var.actor.id; - const attempt = await store.authorizationAttemptFor(actorId); - if (!attempt || attempt.serverId !== serverId) { - return context.json({ cancelled: false }); - } - const cancelled = await store.discardAuthorizationAttempt({ - actorId, - createdAt: attempt.createdAt, - }); - return context.json({ cancelled }); + return context.json({ cancelled: false }); }); /** diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 0a20dbd12..9af13f358 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -25,6 +25,7 @@ const suite = randomUUID().slice(0, 8); const personalApp = `arcade-cp-${suite}`; const sharedApp = `arcade-cs-${suite}`; const otherApp = `arcade-co-${suite}`; +const movedApp = `arcade-cm-${suite}`; const SHARED_VENDOR_ID = `openbot-deployment:acme-${suite}:R`; const ACTION = "Gmail.SendEmail"; const SECRET_URL = "https://vendor.example/consent?secret=bearer-capability"; @@ -173,9 +174,18 @@ beforeAll(async () => { authScheme: "OAUTH2", accountMode: "personal", }, + { + id: movedApp, + title: "Notion", + vendor: "Arcade", + url: `arcade://M${suite}`, + provenance: "arcade", + authScheme: "OAUTH2", + accountMode: "personal", + }, ]); await database.insert(mcpTools).values( - [personalApp, sharedApp, otherApp].map((serverId) => ({ + [personalApp, sharedApp, otherApp, movedApp].map((serverId) => ({ serverId, name: ACTION, })), @@ -185,7 +195,9 @@ beforeAll(async () => { afterAll(async () => { await database .delete(mcpServers) - .where(inArray(mcpServers.id, [personalApp, sharedApp, otherApp])); + .where( + inArray(mcpServers.id, [personalApp, sharedApp, otherApp, movedApp]), + ); if (actors.length) { await database .delete(pendingAuthorizations) @@ -300,4 +312,84 @@ describe("DELETE /servers/:id/authorize", () => { expect(after?.completedAt).toBeNull(); expect(after?.outcome).toBeNull(); }); + + /* + * B5. CANCELLING IS AN OPERATION ON THE ATTEMPT, AND THE ATTEMPT OUTLIVES WHAT THE APP LOOKS + * LIKE. The route used to resolve the app FIRST and refuse on anything `brokeredAccountFor` + * refuses — while reading nothing off its success branch. So every way an app can stop + * resolving, with a live attempt still standing, was a person holding a pending authorization + * they could not retract: the attempt key is the actor ALONE, so that one row also refused them + * a new authorization on ANY app for the full fifteen minutes. + * + * A HARD `DELETE` OF THE SERVER ROW IS NOT ONE OF THOSE WAYS, and that is worth writing down + * rather than testing: `pending_authorizations.server_id` is `references(mcpServers.id, + * { onDelete: "cascade" })`, so `removeServer` takes the attempt with it and there is nothing + * left to cancel. The two below are reachable, and both by an administrator doing something + * ordinary. + */ + test("an attempt on an app whose url no longer parses as brokered can still be cancelled", async () => { + const actor = person("moved"); + const app = appAs("user", actor); + expect((await authorize(app, movedApp)).status).toBe(200); + expect(await attemptsFor(actor)).toHaveLength(1); + + // The app is re-pointed at an ordinary MCP endpoint: `appOfUrl` now answers null for it. + await database + .update(mcpServers) + .set({ url: `https://moved-${suite}.example/mcp` }) + .where(eq(mcpServers.id, movedApp)); + try { + const response = await cancel(app, movedApp); + expect(response.status).toBeLessThan(300); + expect(await response.json()).toEqual({ cancelled: true }); + expect(await attemptsFor(actor)).toEqual([]); + } finally { + await database + .update(mcpServers) + .set({ url: `arcade://M${suite}` }) + .where(eq(mcpServers.id, movedApp)); + } + }); + + test("an administrator demoted mid-consent can still cancel their own Shared-app attempt", async () => { + const actor = person("demoted"); + expect((await authorize(appAs("admin", actor), sharedApp)).status).toBe( + 200, + ); + const [before] = await attemptsFor(actor); + expect(before?.serverId).toBe(sharedApp); + expect(before?.vendorUserId).toBe(SHARED_VENDOR_ID); + + // The same person, no longer an administrator, with their own consent still in flight. + const response = await cancel(appAs("user", actor), sharedApp); + expect(response.status).toBeLessThan(300); + expect(await response.json()).toEqual({ cancelled: true }); + expect(await attemptsFor(actor)).toEqual([]); + }); + + /* + * AND THE REFUSALS ARE NOT TRADED AWAY FOR THAT. With nothing of this actor's to cancel, a + * DELETE aimed at a row that is not brokered is still told so, rather than flattened into a + * `{ cancelled: false }` that claims the request was understood. + */ + test("with no attempt of this actor's, a non-brokered app is still refused in so many words", async () => { + const actor = person("refused"); + const app = appAs("user", actor); + await database + .update(mcpServers) + .set({ url: `https://refused-${suite}.example/mcp` }) + .where(eq(mcpServers.id, movedApp)); + try { + const response = await cancel(app, movedApp); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "That app is not reached through a broker.", + }); + } finally { + await database + .update(mcpServers) + .set({ url: `arcade://M${suite}` }) + .where(eq(mcpServers.id, movedApp)); + } + }); }); From 1169d219ffae2fc00c0f91c5890a15bbcdb5f942 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:46:39 +0200 Subject: [PATCH 174/232] fix(plugins): refuse a late callback that would reopen a settled authorization attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `closeAuthorizationAttempt` matched on `(actor_id, auth_id)` alone. That pair does not stop naming a row when the row settles: a completed attempt still holds the `ar_...` a second return leg matches on. So a slow or retried FAILURE callback arriving behind a SUCCESSFUL completion rewrote `outcome = 'completed'` as `outcome = 'failed'` and re-stamped `completed_at`, while the `brokered_connections` row and the `mcp.account_connected` entry that completion wrote stood untouched. The attempt row and the audit trail then disagreed about what happened to one person's access, and the attempt was the one that was wrong. The statement is now conditional on `completed_at IS NULL`, exactly as `completeAuthorizationAttempt`'s own UPDATE is. The prior comment defended the asymmetry on the grounds that "a second one re-stamping the same verdict grants nothing to anybody" — true of `failed` over `failed`, and silent about the case that actually hurts, a verdict written over a different and already-final one. WHY REFUSE RATHER THAN RECORD SEPARATELY. Three options were weighed, per the brief. - Apply it (the status quo): rejected. It destroys an operator signal rather than adding one — the true outcome is overwritten with a false one. - Record it separately: it wants a new audit event type. `mcp.callback_refused` is the only near fit and is specifically "a caller could not prove which Bot it was" (the admin UI labels it exactly that), so reusing it would mislabel the row. A new type spans `server/src/audit.ts`, `app/src/lib/audit/outcome.ts` and the admin label maps, which is wider than this finding and collides with a wave this change is meant to stay clear of. - Refuse (chosen): the first terminal verdict wins and `false` says this call closed nothing. The signal that matters is not silently dropped — the person on the losing leg still gets the refusal redirect their leg would have produced anyway, and the trail keeps the true outcome. What is still unexpressible is "two legs of one authorization disagreed"; that is reported as a follow-up rather than invented here. CALL SITES. No signature changed; all three public methods still answer `Promise`, with `false` widened by one more reason (the attempt had already settled). Writers: `failAuthorizationAttempt` — `server/src/plugins/routes.ts:3259` (vendor says failed) and `:3264` (vendor's identity is not the attempt's); `markAuthorizationWithdrawn` — `:3370`; `markAuthorizationUnreconciled` — `:3371`. Every one of the four discards the boolean today, so no caller's behaviour changes. The test file is the only other reader. AUDITED, NOT FIXED — the other two writers of `pending_authorizations` have the same shape (reported for the round, deliberately out of scope here): - `discardAuthorizationAttempt` deletes on `(actor_id, created_at)` with no state guard. The cancel route reads a live attempt, then deletes; a completion committing in between makes the DELETE erase a COMPLETED attempt outright, and `{ cancelled: true }` is answered for a consent that succeeded. - `recordAuthorizationMint` writes `auth_id` on `(actor_id, created_at)` with no state guard, so a retried mint can rewrite the `auth_id` of an attempt that has already settled under the old one — including back to `null`. `claimAuthorizationAttempt` is clean: one conditional statement. RED-GREEN. With base `server/src/plugins/store.ts` restored under the new tests, both fail on the row's own state: `expect(after.outcome).toBe("completed")` received `"failed"`, and `expect(after.outcome).toBe("failed")` received `"unreconciled"`. With the guard, 8/8 pass. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 41 ++++++++-- ...completion-transaction.integration.test.ts | 76 +++++++++++++++++++ 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e165d753a..e0a99c602 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1693,9 +1693,35 @@ export function createPluginStore(options: PluginStoreOptions) { ); /** - * The one statement behind `failAuthorizationAttempt` and `markAuthorizationUnreconciled`, so the - * two verdicts cannot drift into two row shapes. `completed_at` is truncated to milliseconds for - * the reason `claimAuthorizationAttempt` truncates `created_at`. + * The one statement behind `failAuthorizationAttempt`, `markAuthorizationUnreconciled` and + * `markAuthorizationWithdrawn`, so the three verdicts cannot drift into three row shapes. + * `completed_at` is truncated to milliseconds for the reason `claimAuthorizationAttempt` + * truncates `created_at`. + * + * CONDITIONAL ON `completed_at IS NULL`, EXACTLY AS THE COMPLETION ABOVE IS, BECAUSE A SETTLED + * ATTEMPT IS A RECORD AND NOT A DRAFT. Without that condition this UPDATE matched on + * `(actor_id, auth_id)` alone, and `(actor_id, auth_id)` does not stop naming a row when the row + * settles: a return leg that completed is still holding the `ar_...` a second leg will match on. + * So a slow or retried FAILURE callback arriving behind a SUCCESSFUL completion rewrote + * `outcome = 'completed'` as `outcome = 'failed'` and re-stamped `completed_at`, and the attempt + * then said the person's authorization failed while the grant was live at the vendor, the + * `brokered_connections` row was written and `mcp.account_connected` was on the trail. The trail + * and the attempt disagreed about what happened, and the attempt was the one that was wrong. + * + * THE OLD COMMENT HERE ARGUED THE ASYMMETRY WAS THE POINT, on the grounds that "a second one + * re-stamping the same verdict grants nothing to anybody". That is true of the case it imagined — + * `failed` over `failed` — and says nothing about the case that actually happens, which is a + * verdict over a DIFFERENT and already-final one. Completion is not the only write that must + * happen once; it is the only one that was treated that way. + * + * FIRST TERMINAL VERDICT WINS, AND A LATE ONE IS REFUSED RATHER THAN APPLIED. `false` now means + * one of three things — no attempt under that actor, an attempt holding a different `auth_id`, + * or an attempt that has already settled — and all three are "this call did not close anything", + * which is the only distinction a caller has ever acted on. Nothing is lost to the person on the + * losing leg: their own request still ends in the refusal redirect it would have ended in anyway. + * What this deployment cannot yet SAY is that two legs of one authorization disagreed; there is + * no audit event type for it and inventing one is wider than this change. Recording the true + * outcome is strictly better than recording the false one, which is what the alternative was. */ const closeAuthorizationAttempt = async ( input: { actorId: string; authId: string }, @@ -1711,6 +1737,7 @@ export function createPluginStore(options: PluginStoreOptions) { and( eq(pendingAuthorizations.actorId, input.actorId), eq(pendingAuthorizations.authId, input.authId), + isNull(pendingAuthorizations.completedAt), ), ) .returning({ actorId: pendingAuthorizations.actorId }); @@ -6703,10 +6730,10 @@ export function createPluginStore(options: PluginStoreOptions) { * all it holds is the `ar_...`. An attempt with no `auth_id` matches nothing, which is correct: * no callback can be about an attempt that never minted one. * - * NOT CONDITIONAL ON `completed_at IS NULL`, unlike the completion above, and the asymmetry is - * the point. Completion is the write that must happen exactly once because it hands out access; - * these two record that it did not, and a second one re-stamping the same verdict grants - * nothing to anybody. + * CONDITIONAL ON `completed_at IS NULL`, exactly as the completion above is: an attempt that + * has settled — completed, failed, unreconciled or withdrawn — is not reopened by a later + * callback, and `false` says so. See {@link closeAuthorizationAttempt} for what the asymmetry + * used to be and what it cost. */ async failAuthorizationAttempt(input: { actorId: string; diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index cea1d5d30..b348c9a30 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -359,4 +359,80 @@ describe("failAuthorizationAttempt / markAuthorizationUnreconciled", () => { expect(attempt.outcome).toBe("unreconciled"); expect(attempt.completedAt).not.toBeNull(); }); + + /* + * THE CASE THE `(actor_id, auth_id)` MATCH DOES NOT EXCLUDE ON ITS OWN. A completed attempt is + * still holding the `ar_...` the failing leg matches on, so without `completed_at IS NULL` this + * close rewrote a person's SUCCESSFUL authorization as a failure — while the connection row and + * the `mcp.account_connected` entry written by that completion stood. The attempt and the trail + * then disagreed, and the attempt was the one that was wrong. + * + * Sequential rather than concurrent, deliberately, and it is not a weaker test: the failing leg + * is a LATE callback and the thing under test is the state of the committed row it arrives at, + * not a lock ordering. Concurrency is what `completeAuthorizationAttempt`'s own race test proves. + */ + test("a late failure callback does NOT reopen an attempt that already completed", async () => { + const a = actor("late-fail"); + const app = toolkit("late-fail"); + await attemptWith(a, "ar_late_fail"); + + await store.completeAuthorizationAttempt({ + actorId: a, + authId: "ar_late_fail", + toolkit: app, + account: account(a), + action: "Google.SendEmail", + scopes: ["gmail.send"], + authProvider: "google", + }); + const completed = await attemptRow(a); + expect(completed.outcome).toBe("completed"); + + /* + * THE ROW IS ASSERTED BEFORE THE RETURN VALUE, so a regression here reads as the corruption it + * is rather than as a boolean that came back the wrong way. The boolean is checked too, below. + */ + const closed = await store.failAuthorizationAttempt({ + actorId: a, + authId: "ar_late_fail", + }); + + const after = await attemptRow(a); + expect(after.outcome).toBe("completed"); + // NOT MERELY "still terminal": the timestamp must be the completion's own, because re-stamping + // it would move when this grant was obtained even where the verdict survived. + expect(after.completedAt).toEqual(completed.completedAt); + // The grant the attempt is the record OF is untouched, which is the half a reader checks next. + expect((await connectionsFor(app)).length).toBe(1); + expect((await connectedEventsFor(app)).length).toBe(1); + expect(closed).toBe(false); + }); + + /* The same rule among the three non-completion verdicts: the first one to settle the row wins. */ + test("a second verdict does not overwrite the first one an attempt settled on", async () => { + const a = actor("settled"); + await attemptWith(a, "ar_settled"); + + expect( + await store.failAuthorizationAttempt({ + actorId: a, + authId: "ar_settled", + }), + ).toBe(true); + const failed = await attemptRow(a); + + const withdrew = await store.markAuthorizationWithdrawn({ + actorId: a, + authId: "ar_settled", + }); + const unreconciled = await store.markAuthorizationUnreconciled({ + actorId: a, + authId: "ar_settled", + }); + + const after = await attemptRow(a); + expect(after.outcome).toBe("failed"); + expect(after.completedAt).toEqual(failed.completedAt); + expect([withdrew, unreconciled]).toEqual([false, false]); + }); }); From dd009089ef5fb89f0f5693ab5b2fc16e7b924c08 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:48:20 +0200 Subject: [PATCH 175/232] test(composio): make the grantsFor suite able to see the isolation it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `server/tests/composio-grants-for.test.ts` named `grantsFor`'s scope isolation and could not observe any of it. Three defects, one subject. 1. THE FAKE VENDOR IGNORED ITS QUERY. All three listings returned every fixture row whatever they were asked for, so `grantsFor` asking about the WRONG PERSON, dropping the ACTIVE filter, or reading ANOTHER TOOLKIT's configs and actions all still passed. `connectedAccounts.list` now filters on `userIds`, `toolkitSlugs` and `statuses`; `authConfigs.list` on `toolkit`; `tools.list` on `toolkit_slug`. Fixtures gained the routing facts beside the row the vendor would send, kept apart so they cannot reach the adapter as fields. 2. NO NON-`(OpenBot)` CONFIG FIXTURE. The configs listing is scoped to the project, not to this deployment, so an operator's hand-made config comes back beside ours on every read and `madeHere` is the only thing telling them apart. With no such row present, `ours` and `held` answered identically at `composio-adapter.ts:3113` and the named mutation survived. Every gmail and firecrawl fixture now carries one, with a scope (`gmail.modify`, `firecrawl.everything`) that appears nowhere else. 3. A TAUTOLOGY WHERE THE CLAIM SHOULD HAVE BEEN. `expect(flow).not.toBeNull()` on an object literal three lines above it is true of every object ever written and silent on the test's own name. It now builds the broker, asserts the flow is reachable through the declared `authorizations` field, and calls both members through it. Also: `ACCOUNT.userId` and `ACCOUNT.vendorUserId` were both `user_1`, which made the local id and the vendor's interchangeable in every assertion here. They now differ. New coverage: somebody else's account; each of the six non-ACTIVE statuses; one app's account, config and actions not borrowed by another's. CALL SITES: none outside this file. `vendorWith`, `ACCOUNT`, the fixture types and the two shared config constants are all file-local; `server/src/` is untouched. WATCHED FAILING (plant, observe, revert — `server/src` verified clean after each): - `ours` -> `held: ours` at composio-adapter.ts:3113 -> 11 of 14 fail, `+ "gmail.modify"`. - `account.vendorUserId` -> `account.userId` -> 3 fail, `authorized` -> `needs-consent`. - `account.vendorUserId` -> `"composio_user_2"` -> 4 fail, including the somebody-else test flipping `needs-consent` -> `authorized`. - `CONNECTED` -> `REVOCABLE` -> 5 fail, one per non-ACTIVE status REVOCABLE admits. - `configsFor(toolkit)` -> `configsFor("slack")` -> 10 fail. - `authorizations: flow` -> `null` in the replaced tautology -> fails on the presence test. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/composio-grants-for.test.ts | 415 +++++++++++++++++++---- 1 file changed, 357 insertions(+), 58 deletions(-) diff --git a/server/tests/composio-grants-for.test.ts b/server/tests/composio-grants-for.test.ts index 05709c9ee..e741782c7 100644 --- a/server/tests/composio-grants-for.test.ts +++ b/server/tests/composio-grants-for.test.ts @@ -20,6 +20,13 @@ import { * the same verdict on each, and the scopes read off this deployment's own config rather than * invented per action. * + * THE FAKE VENDOR BELOW FILTERS, AND THAT IS THE OTHER HALF OF WHAT THIS FILE CLAIMS. A fake that + * hands back every fixture row whatever it was asked for cannot tell a correct question from a + * wrong one, so a suite built on one asserts the SHAPE of `grantsFor`'s answer while leaving the + * three things that make the answer this person's — which person, which statuses count as + * connected, and which app — entirely unguarded. Every listing here therefore honours the query it + * is given, and every test below carries at least one row the query must leave out. + * * This file is named in `server/tsconfig.json`'s `include`, because the last describe is a TYPE * test and a `@ts-expect-error` inside an unchecked directory asserts nothing at all. */ @@ -29,13 +36,60 @@ const refuse = (what: string) => async (): Promise => { throw new Error(`${what} should not have been called in this test.`); }; -/** The person every fixture here asks about. */ +/** + * The person every fixture here asks about. + * + * THE TWO IDS DIFFER, which they did not when this file was written. `userId` is this deployment's + * own id for somebody and `vendorUserId` is the id Composio knows them by; spelling both as + * `user_1` made the two interchangeable in every assertion here, so a listing asked with the local + * id — which is not an id Composio has ever seen — read exactly like one asked with the vendor's. + */ const ACCOUNT = { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: "composio_user_1", } as const; +/** Somebody else at this deployment, whose rows no question about {@link ACCOUNT} may return. */ +const OTHER_PERSON = "composio_user_2"; + +/** + * The vendor's account statuses, spelled here because the adapter keeps its copy private. + * + * Written as a union rather than as `string` so that a fixture naming a status the vendor does not + * publish is a type error here instead of a row that silently matches nothing and leaves the test + * it belongs to passing for the wrong reason. + */ +type AccountStatus = + | "INITIALIZING" + | "INITIATED" + | "ACTIVE" + | "FAILED" + | "EXPIRED" + | "INACTIVE" + | "REVOKED"; + +/** + * A connected account as the vendor holds it: the row it would send, and the three facts the + * listing is filtered on. The two are kept apart so that the routing facts cannot leak into the + * row and be read by the adapter as fields the vendor sent. + */ +type AccountFixture = { + row: { id?: unknown; data?: unknown }; + userId: string; + status: AccountStatus; + toolkit: string; +}; + +/** An auth config as the vendor holds it, beside the app whose listing returns it. */ +type ConfigFixture = { + row: { id?: unknown; name?: unknown; status?: unknown; scopes?: unknown }; + toolkit: string; +}; + +/** A published action, beside the app whose listing returns it. */ +type ActionFixture = { row: { slug: string }; toolkit: string }; + /** * A vendor answering the three listings `grantsFor` reads, and refusing everything else. * @@ -43,20 +97,26 @@ const ACCOUNT = { * the app, which of this deployment's own auth configs stand for it (and what scopes they carry), * and which actions the app publishes. A fourth call is a change in what the method does, and it * names itself here rather than passing. + * + * EACH LISTING HONOURS ITS QUERY. `connectedAccounts.list` is filtered by `userIds`, `statuses` + * and `toolkitSlugs`; `authConfigs.list` by `toolkit`; `tools.list` by `toolkit_slug`. Those are + * the parameters that carry the whole of the scope isolation this file is about, and a fake that + * ignored them would answer "yes, connected, here are the scopes" to a question about the wrong + * person, about a half-finished consent, or about another app entirely. */ function vendorWith(parts: { - accounts: { id?: unknown; data?: unknown }[]; - configs: { - id?: unknown; - name?: unknown; - status?: unknown; - scopes?: unknown; - }[]; - actions: { slug: string }[]; + accounts: AccountFixture[]; + configs: ConfigFixture[]; + actions: ActionFixture[]; }): ComposioVendor { return { tools: { - list: async () => ({ items: parts.actions, next_cursor: null }), + list: async (query) => ({ + items: parts.actions + .filter((action) => action.toolkit === query.toolkit_slug) + .map((action) => action.row), + next_cursor: null, + }), getRawComposioToolBySlug: refuse("tools.getRawComposioToolBySlug"), execute: refuse("tools.execute"), }, @@ -65,12 +125,29 @@ function vendorWith(parts: { retrieve: refuse("toolkits.retrieve"), }, authConfigs: { - list: async () => ({ items: parts.configs, nextCursor: null }), + list: async (query) => ({ + items: parts.configs + .filter((config) => config.toolkit === query.toolkit) + .map((config) => config.row), + nextCursor: null, + }), create: refuse("authConfigs.create"), delete: refuse("authConfigs.delete"), }, connectedAccounts: { - list: async () => ({ items: parts.accounts, nextCursor: null }), + list: async (query) => ({ + items: parts.accounts + .filter( + (account) => + query.userIds.includes(account.userId) && + query.toolkitSlugs.includes(account.toolkit) && + // `some` rather than `includes` only because the query's element type is the + // adapter's own private union: the comparison is the same one. + query.statuses.some((status) => status === account.status), + ) + .map((account) => account.row), + nextCursor: null, + }), link: refuse("connectedAccounts.link"), create: refuse("connectedAccounts.create"), delete: refuse("connectedAccounts.delete"), @@ -78,23 +155,55 @@ function vendorWith(parts: { }; } +/** This deployment's own gmail config, which is where a gmail answer's scopes must come from. */ +const OUR_GMAIL_CONFIG: ConfigFixture = { + row: { + id: "ac_1", + name: "gmail (OpenBot)", + status: "ENABLED", + scopes: ["gmail.readonly", "gmail.send"], + }, + toolkit: "gmail", +}; + +/** + * A gmail config an operator made by hand in Composio's dashboard, carrying a scope nothing here + * chose. + * + * IT IS IN EVERY GMAIL FIXTURE BELOW ON PURPOSE. The configs listing is scoped to the project + * rather than to this deployment, so this row comes back beside ours on every read, and the only + * thing telling the two apart is the `(OpenBot)` suffix. Without such a row present, `grantsFor` + * reading `held` instead of `ours` answers identically to `grantsFor` reading `ours` — which is to + * say the suite could not see the distinction it is named after. `gmail.modify` appears nowhere + * else in this file, so it names which side of the filter an answer came from. + */ +const OPERATORS_GMAIL_CONFIG: ConfigFixture = { + row: { + id: "ac_0", + name: "gmail hand-made by an operator", + status: "ENABLED", + scopes: ["gmail.modify"], + }, + toolkit: "gmail", +}; + describe("Composio's grantsFor", () => { test("answers one state per action, the same one for each, carrying the config's scopes", async () => { const { broker } = buildComposioClient( vendorWith({ - accounts: [{ id: "ca_1" }], - configs: [ + accounts: [ { - id: "ac_1", - name: "gmail (OpenBot)", - status: "ENABLED", - scopes: ["gmail.readonly", "gmail.send"], + row: { id: "ca_1" }, + userId: ACCOUNT.vendorUserId, + status: "ACTIVE", + toolkit: "gmail", }, ], + configs: [OPERATORS_GMAIL_CONFIG, OUR_GMAIL_CONFIG], actions: [ - { slug: "GMAIL_FETCH_EMAILS" }, - { slug: "GMAIL_SEND_EMAIL" }, - { slug: "GMAIL_CREATE_LABEL" }, + { row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }, + { row: { slug: "GMAIL_SEND_EMAIL" }, toolkit: "gmail" }, + { row: { slug: "GMAIL_CREATE_LABEL" }, toolkit: "gmail" }, ], }), ); @@ -126,6 +235,16 @@ describe("Composio's grantsFor", () => { expect(new Set(grants.map((grant) => grant.state))).toEqual( new Set(["authorized"]), ); + // And the operator's scope is named rather than merely absent from the list above, so that a + // reader can see which of the two configs this answer was allowed to read. + // `scopes` lives on two of {@link GrantState}'s four members, so the narrowing is part of the + // claim: a grant that arrived as `needs-operator` or `unknown` carries no scope list at all + // and would make this assertion vacuous by having nothing to look through. + expect( + grants.flatMap((grant) => + "scopes" in grant ? grant.scopes : [`no scopes on a ${grant.state}`], + ), + ).not.toContain("gmail.modify"); }); test("answers needs-consent for each action where this person has connected nothing", async () => { @@ -134,22 +253,26 @@ describe("Composio's grantsFor", () => { accounts: [], configs: [ { - id: "ac_1", - name: "gmail (OpenBot)", - status: "ENABLED", - scopes: ["gmail.readonly"], + row: { + id: "ac_1", + name: "gmail (OpenBot)", + status: "ENABLED", + scopes: ["gmail.readonly"], + }, + toolkit: "gmail", }, + OPERATORS_GMAIL_CONFIG, + ], + actions: [ + { row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }, + { row: { slug: "GMAIL_SEND_EMAIL" }, toolkit: "gmail" }, ], - actions: [{ slug: "GMAIL_FETCH_EMAILS" }, { slug: "GMAIL_SEND_EMAIL" }], }), ); - const grants = await broker.grantsFor({ - toolkit: "gmail", - account: ACCOUNT, - }); - - expect(grants).toEqual([ + expect( + await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), + ).toEqual([ { action: "GMAIL_FETCH_EMAILS", state: "needs-consent", @@ -163,6 +286,140 @@ describe("Composio's grantsFor", () => { ]); }); + /** + * SOMEBODY ELSE'S CONNECTION IS NOT THIS PERSON'S, and the only thing that makes it so is the + * `userIds` the listing is asked with. This is the gate `./access` leans on before running an + * action, so reading it off the wrong person is a call made under a grant its caller never gave. + */ + test("answers needs-consent where the only account for the app belongs to somebody else", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [ + { + row: { id: "ca_other" }, + userId: OTHER_PERSON, + status: "ACTIVE", + toolkit: "gmail", + }, + ], + configs: [OUR_GMAIL_CONFIG, OPERATORS_GMAIL_CONFIG], + actions: [{ row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }], + }), + ); + + expect( + await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), + ).toEqual([ + { + action: "GMAIL_FETCH_EMAILS", + state: "needs-consent", + scopes: ["gmail.readonly", "gmail.send"], + }, + ]); + }); + + /** + * A GRANT THAT NO LONGER OPENS ANYTHING, OR NEVER DID, IS NOT A CONNECTION — see `CONNECTED` in + * the adapter, which is `["ACTIVE"]` and nothing else. Each row below is a state the vendor will + * hand back to a listing that forgot to ask, and each one of them reported as `authorized` tells + * a person their app is wired up and then fails every call they make with it. + */ + test.each([ + ["INITIATED" as const], + ["EXPIRED" as const], + ["INACTIVE" as const], + ["REVOKED" as const], + ["FAILED" as const], + ["INITIALIZING" as const], + ])( + "answers needs-consent where this person's only account is %s", + async (status) => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [ + { + row: { id: "ca_1" }, + userId: ACCOUNT.vendorUserId, + status, + toolkit: "gmail", + }, + ], + configs: [OUR_GMAIL_CONFIG, OPERATORS_GMAIL_CONFIG], + actions: [{ row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }], + }), + ); + + expect( + await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), + ).toEqual([ + { + action: "GMAIL_FETCH_EMAILS", + state: "needs-consent", + scopes: ["gmail.readonly", "gmail.send"], + }, + ]); + }, + ); + + /** + * ONE APP'S ANSWER IS ABOUT THAT APP. All three listings carry the toolkit, and all three of them + * are wrong in a different way without it: another app's account makes this one read connected, + * another app's config lends this one scopes nobody granted here, and another app's actions + * arrive as entries in a grant list for an app that does not publish them. + */ + test("reads the account, the config and the actions of the app asked about and no other", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [ + { + row: { id: "ca_slack" }, + userId: ACCOUNT.vendorUserId, + status: "ACTIVE", + toolkit: "slack", + }, + ], + configs: [ + OUR_GMAIL_CONFIG, + OPERATORS_GMAIL_CONFIG, + { + row: { + id: "ac_2", + name: "slack (OpenBot)", + status: "ENABLED", + scopes: ["slack.chat.write"], + }, + toolkit: "slack", + }, + ], + actions: [ + { row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }, + { row: { slug: "SLACK_SEND_MESSAGE" }, toolkit: "slack" }, + ], + }), + ); + + // This person is connected to slack and not to gmail, and the gmail answer says so without + // borrowing slack's action or slack's scope. + expect( + await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), + ).toEqual([ + { + action: "GMAIL_FETCH_EMAILS", + state: "needs-consent", + scopes: ["gmail.readonly", "gmail.send"], + }, + ]); + expect( + await broker.grantsFor({ toolkit: "slack", account: ACCOUNT }), + ).toEqual([ + { + action: "SLACK_SEND_MESSAGE", + state: "authorized", + scopes: ["slack.chat.write"], + }, + ]); + }); + /** * A config publishing no scope set is an empty list rather than a refusal, because this method * REPORTS rather than acts: Composio publishes no scopes for a key-scheme config at all, and an @@ -171,11 +428,30 @@ describe("Composio's grantsFor", () => { test("carries an empty scope list where the config publishes none", async () => { const { broker } = buildComposioClient( vendorWith({ - accounts: [{ id: "ca_1" }], + accounts: [ + { + row: { id: "ca_1" }, + userId: ACCOUNT.vendorUserId, + status: "ACTIVE", + toolkit: "firecrawl", + }, + ], configs: [ - { id: "ac_1", name: "firecrawl (OpenBot)", status: "ENABLED" }, + { + row: { id: "ac_1", name: "firecrawl (OpenBot)", status: "ENABLED" }, + toolkit: "firecrawl", + }, + { + row: { + id: "ac_0", + name: "firecrawl hand-made by an operator", + status: "ENABLED", + scopes: ["firecrawl.everything"], + }, + toolkit: "firecrawl", + }, ], - actions: [{ slug: "FIRECRAWL_SCRAPE" }], + actions: [{ row: { slug: "FIRECRAWL_SCRAPE" }, toolkit: "firecrawl" }], }), ); @@ -213,27 +489,28 @@ describe("Composio's authorizations", () => { * missing `authorizations` must not compile, and a literal declaring it `null` must. */ describe("the declared sub-surface", () => { - test("a broker literal omitting authorizations does not compile", () => { - const members = { - grantIsLocal: true, - // Nothing here asks what auth provider a connection is held under. - authProviderOf: async () => null, - grantsFor: async (): Promise => [], - listApps: async () => [], - ensureAuthConfig: async () => "not-needed" as const, - deleteAuthConfig: async () => {}, - authorize: async () => ({ - redirectUrl: "https://openbot.test/x", - authorizationId: null, - }), - isConnected: async () => false, - revoke: async () => false, - connectionFields: async () => [], - connectWithFields: async () => ({ accountId: "ca_1" }), - revokeAccount: async () => {}, - accountName: async () => null, - }; + /** Every member of the seam except `authorizations`, which is what the two tests vary. */ + const members = { + grantIsLocal: true, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, + grantsFor: async (): Promise => [], + listApps: async () => [], + ensureAuthConfig: async () => "not-needed" as const, + deleteAuthConfig: async () => {}, + authorize: async () => ({ + redirectUrl: "https://openbot.test/x", + authorizationId: null, + }), + isConnected: async () => false, + revoke: async () => false, + connectionFields: async () => [], + connectWithFields: async () => ({ accountId: "ca_1" }), + revokeAccount: async () => {}, + accountName: async () => null, + }; + test("a broker literal omitting authorizations does not compile", () => { // @ts-expect-error `authorizations` is required: a broker must SAY whether it runs a // correlated, pollable authorization flow, and an omission is not an answer. const omitted: ConnectedAppBroker = members; @@ -243,12 +520,34 @@ describe("the declared sub-surface", () => { expect(declared.authorizations).toBeNull(); }); - test("a broker that runs the flow declares the sub-surface instead", () => { + /** + * THE OTHER HALF, AND IT IS ABOUT THE BROKER RATHER THAN ABOUT THE FLOW. This used to read + * `expect(flow).not.toBeNull()` on an object literal three lines above it — true of every object + * ever written, and silent on the one thing the test is named for: that a broker which RUNS the + * flow carries it on the declared field, where a route's presence test finds it and reaches the + * two members through it. + */ + test("a broker that runs the flow declares the sub-surface instead", async () => { const flow: AuthorizationFlow = { status: async () => ({ status: "completed", userId: null, scopes: [] }), confirmReturn: async () => ({ mismatch: true }), }; - expect(flow).not.toBeNull(); + const declared: ConnectedAppBroker = { ...members, authorizations: flow }; + + // The presence test a route makes, and then the flow itself, reached only through the field. + expect(declared.authorizations).not.toBeNull(); + expect(declared.authorizations).toBe(flow); + expect(await declared.authorizations?.status("auth_1")).toEqual({ + status: "completed", + userId: null, + scopes: [], + }); + expect( + await declared.authorizations?.confirmReturn({ + handle: "flow_1", + vendorUserId: ACCOUNT.vendorUserId, + }), + ).toEqual({ mismatch: true }); }); }); From e925cd2b1146eee06aac1bf463b9efad26b59501 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:48:27 +0200 Subject: [PATCH 176/232] fix(plugins): one place for the three rules a cascade delete obeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disconnectBrokered` deleted a KEY sibling's `brokered_connections` row although the single OAuth revoke never ended it, leaving a LIVE broker account with no local row to reach it. `keyApps` only weakened the trail claim; it never gated the delete. It also deleted a sibling's row on a revoke that ANSWERED FALSE, and wrote a trail row for a sibling whose row was never here. `clearWithdrawnAccount` skipped all three cases, citing `removeServer` as the source. `removeServer`'s own sibling loop keeps two of the three. Three spellings of one decision, disagreeing. DIRECTION. The newer sibling is right, and the reason is asymmetric cost: a row deleted in a cascade belongs to an app NOBODY ASKED ABOUT, so getting it wrong strands a grant the person still holds at the vendor and can no longer reach from here, while leaving a row standing is recoverable by pressing Disconnect again. `removeServer` is the oldest spelling but not the most careful one. So the rules are not copied between the two: they are extracted into one store function, `clearEndedSiblings`, which is now the only thing in the file that deletes a row for an unasked app. 1. a key is not what an OAuth revoke ended — no delete, no trail row; 2. only from a revoke that ANSWERED TRUE; 3. only a row that was actually here gets a trail row. The ASKED app keeps its own treatment in both callers, which is not a cascade: `disconnectBrokered` still deletes its row whatever its kind (that is the act the person pressed) and still files `deleted || ended` (a withdrawal the vendor made belongs on the trail even where no row was held), and the key kind still decides only what `vendorRevocationRequested` claims about it. `BrokeredDisconnectReason` is the closed set of words `mcp.account_disconnected` is filed under, declared once so the shared writer takes the word rather than re-deciding it. It is wider than either caller's own `reason` so `removeServer`'s `mcp_server_removed` needs no change here when its loop joins them. CALL SITES. `clearEndedSiblings` is new and has exactly two: `disconnectBrokered` and `clearWithdrawnAccount`. Neither method's signature or return changed, so their callers are untouched — `routes.ts:2604`, `account-mode.ts:208` and `routes.ts:3359`. `BrokeredDisconnectReason` is additive and nothing narrows to it yet. No other module deletes a `brokered_connections` row. NOT FIXED, REPORTED. `removeServer`'s sibling loop (store.ts, the `endedBesides` walk) is the third site and is still missing rule 1: it deletes a key sibling's row on another app's revoke. Same class, different lines, and another agent in this wave may own them — see round-2-fix-A5.md. RED-GREEN. The three new tests in `arcade-disconnect-cascade.test.ts` were run against the base `store.ts` copied into the worktree: 3 fail / 4 pass, each red from production behaviour (a key sibling's row gone, a sibling's row gone on a false revoke, a trail row for a row that was never here). With the fix restored: 7 pass / 0 fail. `arcade-revoke-invariant.test.ts` untouched and still 7/0. The `assertion-shape` allow-list entry for this file is re-keyed 36 -> 39: the same offence moved, not a new or a cleared one. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/store.ts | 349 +++++++++++++----- .../tests/arcade-disconnect-cascade.test.ts | 93 ++++- server/tests/assertion-shape.test.ts | 4 +- 3 files changed, 348 insertions(+), 98 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e165d753a..2cad75e1c 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1251,6 +1251,29 @@ export type PluginStoreOptions = { /** One in-flight Arcade authorization attempt, as the table stores it. */ export type PendingAuthorization = typeof pendingAuthorizations.$inferSelect; +/** + * WHY A BROKERED ACCOUNT'S ROW WENT, as the trail spells it, and a closed set rather than free + * text for the reason {@link PluginStore.disconnectBrokered} gives: a reader asking the trail + * WHICH of these happened can be answered only if it is the same word every time. + * + * ONE SET ACROSS EVERY WRITER OF `mcp.account_disconnected`, so that `clearEndedSiblings` — which + * writes for all of them — takes the word rather than re-deciding it. Each writer still narrows + * this to the words IT may use; nothing takes the whole union as its own input. + */ +export type BrokeredDisconnectReason = + /** The person disconnected their own account. */ + | "self" + /** The person was removed from the People screen; {@link PluginStore.retireConnectionsFor}. */ + | "person_removed" + /** An administrator disconnected the deployment's account for a Shared app. */ + | "admin" + /** The app was switched between Personal and Shared, retiring the old mode's accounts. */ + | "mode_switched" + /** A consent's context moved on and the verifier withdrew the retired identity's app. */ + | "context_changed" + /** The app itself was removed; {@link PluginStore.removeServer}. */ + | "mcp_server_removed"; + /** One app's connection, as far as sibling-finding needs to know it. */ export type ConnectionFootprint = { /** The toolkit, spelled as the vendor spells it. Compared exactly, never case-folded. */ @@ -3098,6 +3121,108 @@ export function createPluginStore(options: PluginStoreOptions) { }; } + /** + * THE LOCAL ROWS ONE REVOKE'S CASCADE ENDS, AND THE THREE RULES THAT DECIDE WHICH — in ONE place, + * because they were in two and the two disagreed about all three. + * + * WHAT A CASCADE IS. {@link resolveSiblings} answers which other apps share the connection the + * caller's single revoke withdrew; where a connection is keyed `(identity, auth provider)` — + * Arcade — that one ask ended them too, so their rows here would otherwise go on claiming + * accounts the vendor no longer holds. This is the write that ends them, and it is the ONLY + * thing in this file that deletes a row for an app NOBODY ASKED ABOUT. That is why the rules are + * a function rather than a convention: a row deleted here is a grant somebody still holds at the + * vendor and can no longer reach from this deployment, and the cost of getting it wrong is not + * symmetric with the cost of leaving a row standing. + * + * RULE 1 — AN APP THIS DEPLOYMENT HOLDS A KEY FOR IS NOT WHAT THAT REVOKE ENDED, so its row + * stands and the trail says nothing about it. The withdrawal ended an OAuth connection; a key is + * a credential of this deployment's own and no consent revoke at the vendor reaches it. Deleting + * it destroys a credential nothing withdrew and leaves a LIVE account with no local row to reach + * it; recording it claims a vendor ended something it never held — the reading + * {@link disconnectBrokered} writes `vendorRevocationRequested: false` for, there on an act that + * really was about that app. The scheme is read off `mcp_servers`, which this never deletes from. + * + * RULE 2 — ONLY FROM A REVOKE THAT ANSWERED TRUE (`revoked`). `false` means nothing was found to + * withdraw, or the auth provider could not be resolved, and the vendor was not shown to have + * ended ANYTHING; a sibling's row is then left standing rather than claimed gone on the strength + * of an answer nobody got. It is a parameter and not a caller's discipline because a caller's + * discipline is exactly what one of the two callers did not keep. + * + * RULE 3 — ONLY A ROW THAT WAS ACTUALLY HERE GETS A TRAIL ROW (`if (!deleted) continue`). These + * rows exist to explain why an app's connection VANISHED from this deployment's records, and an + * app that had no row here had nothing to explain. A sibling resolved on this identity but held + * by somebody else — {@link resolveSiblings} reads the identity, {@link accountRow} the holder — + * is exactly that case, and it used to get a row saying it had been disconnected. + * + * ALL THREE ARE {@link removeServer}'S, which is the oldest spelling of this cascade and the one + * both callers cite; it keeps 2 and 3 in its own sibling loop. Its loop is NOT routed through + * here yet and so is still missing rule 1 — see the report for round 2's finding A5. + * + * `vendorRevocationRequested` IS TRUE ON EVERY ROW THIS WRITES AND IS NOT A GUESS: rule 2 says a + * revoke answered true, and that ask ended the siblings with the app it named. `false` means "no + * vendor was asked", which is the one thing none of these rows may say. `endedWith` names the app + * that asked — the only thing that tells a reader why a connection they never touched is gone — + * and `unansweredSiblings` qualifies that claim, beside it and nowhere else. + * + * IT ASKS NOTHING OF ANY VENDOR. The caller revoked, before it called this; see each caller. + * + * Answers the apps it actually cleared, which is what {@link clearWithdrawnAccount} returns and + * what a caller needs in order to say what went. + */ + async function clearEndedSiblings(input: { + /** {@link resolveSiblings}' answer for `endedWith`. Deduplicated here; order is its own. */ + siblings: readonly string[]; + account: AccountRef; + /** The app whose revoke this cascade belongs to. Never cleared here — it has its own row. */ + endedWith: string; + /** What that revoke ANSWERED. Rule 2. */ + revoked: boolean; + /** {@link resolveSiblings}' count, an integer and never names. */ + unanswered: number; + by: string; + /** + * The trail's one word for why, from the closed set the writers of this event use. Wider than + * either caller's own `reason` so that {@link removeServer}'s `mcp_server_removed` needs no + * change here when its loop joins them. + */ + reason: BrokeredDisconnectReason; + }): Promise { + if (!input.revoked) return []; + const cleared: string[] = []; + for (const app of new Set(input.siblings)) { + if (app === input.endedWith) continue; + if ((await brokeredAppKind(app)) === "key") continue; + // `returning` because whether a row was here is the whole of rule 3, and a delete that + // answered nothing would leave the two cases indistinguishable. + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(app, input.account)) + .returning({ app: brokeredConnections.app }); + if (!deleted) continue; + cleared.push(app); + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: app, + payload: { + actor: input.by, + server: app, + // Whose account this was, which is not always who ended it; named as the deployment + // where there is no person behind it. + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + vendorRevocationRequested: true, + unansweredSiblings: input.unanswered, + endedWith: input.endedWith, + }, + }); + } + return cleared; + } + return { /** * The one provider this store's rows belong to, readable by the layer above it. @@ -8084,7 +8209,10 @@ export function createPluginStore(options: PluginStoreOptions) { * and an app it cannot say holds a key is one whose withdrawal it has to report as asked. */ /** - * WHAT THIS DISCONNECT CLAIMS ON THE TRAIL FOR EACH KIND OF APP. + * WHAT THIS DISCONNECT CLAIMS ON THE TRAIL FOR THE ASKED APP, BY KIND. What it does about a + * SIBLING of each kind is a different decision and is written down where it is made — see + * rule 1 on {@link clearEndedSiblings}, which for a key DELETES NOTHING AND CLAIMS NOTHING + * rather than claiming no revocation about a row it removed. * * Type-only and erased; see {@link Decides}. `=== "key"` collapses three answers into two, * and the collapse is deliberate here rather than accidental: the field below is a claim that @@ -8114,11 +8242,19 @@ export function createPluginStore(options: PluginStoreOptions) { account: input.account, }); - // Read while the rows are still here: the scheme is on the row, and the delete below ends it. - const keyApps = new Set(); - for (const app of [input.toolkit, ...siblings]) { - if ((await brokeredAppKind(app)) === "key") keyApps.add(app); - } + /* + * WHETHER THE ASKED APP IS ONE THIS DEPLOYMENT HOLDS A KEY FOR, which decides WHAT THE TRAIL + * CLAIMS about it and nothing else. Its row goes either way: the person pressed Disconnect on + * THIS app, the row is the whole of what Disconnect works off, and leaving it would be + * refusing the act. The siblings are the opposite question — nobody asked about them — and + * {@link clearEndedSiblings} answers it, rule 1 included. + * + * This was one `keyApps` set spanning the asked app and its siblings, and the set only ever + * weakened the trail claim: a KEY sibling's row was deleted anyway, by the single OAuth + * revoke that never ended it, leaving a live account at the vendor with no local row to + * reach it. + */ + const askedIsKey = (await brokeredAppKind(input.toolkit)) === "key"; // Whether there was an account to end at all, which is what decides if anybody was // disconnected. Named apart from the field below because for a key the two differ: something @@ -8133,53 +8269,66 @@ export function createPluginStore(options: PluginStoreOptions) { toolkit: input.toolkit, }); - // Every app that ended, the asked one first. Its audit row is its own; a sibling's names the - // app whose disconnect ended it. - for (const app of [input.toolkit, ...siblings]) { - const vendorRevocationRequested = keyApps.has(app) ? false : ended; + const vendorRevocationRequested = askedIsKey ? false : ended; - // `returning` because whether a row was here is half of what decides if anybody was - // disconnected, and a delete that answered nothing would leave the two cases - // indistinguishable. - const [deleted] = await database - .delete(brokeredConnections) - .where(accountRow(app, input.account)) - .returning({ app: brokeredConnections.app }); + /* + * THE ASKED APP FIRST, and on its own terms — this is the act, not a cascade of it. + * + * `deleted || ended` RATHER THAN RULE 3, deliberately and only here: a disconnect of an app + * whose row this deployment never held is still a withdrawal the vendor made, and the trail + * has to carry it. A SIBLING has no such act of its own to record, which is why + * {@link clearEndedSiblings} files nothing without a row. + * + * `returning` because whether a row was here is half of what decides if anybody was + * disconnected, and a delete that answered nothing would leave the two cases + * indistinguishable. + */ + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(input.toolkit, input.account)) + .returning({ app: brokeredConnections.app }); - if (deleted || ended) { - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: app, - payload: { - actor: input.by, - server: app, - // Whose account this was, which is not always who ended it: an administrator - // offboarding somebody and a person disconnecting themselves write the same shape of - // row, and only these two fields tell them apart. The deployment's own account has - // no person behind it, so it is named as the deployment rather than as whoever - // pressed. - owner: - input.account.holder === "person" - ? input.account.userId - : "deployment", - reason: input.reason, - vendorRevocationRequested, - /* - * HOW MANY apps on this identity could not be asked about and so were left out of - * the set above, never their names. An integer, so an operator can see that "we did - * not know" happened; see {@link resolveSiblings}. - */ - unansweredSiblings: unanswered, - ...(app === input.toolkit ? {} : { endedWith: input.toolkit }), - }, - }); - } + if (deleted || ended) { + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + server: input.toolkit, + // Whose account this was, which is not always who ended it: an administrator + // offboarding somebody and a person disconnecting themselves write the same shape of + // row, and only these two fields tell them apart. The deployment's own account has + // no person behind it, so it is named as the deployment rather than as whoever + // pressed. + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + vendorRevocationRequested, + /* + * HOW MANY apps on this identity could not be asked about and so were left out of + * the set below, never their names. An integer, so an operator can see that "we did + * not know" happened; see {@link resolveSiblings}. + */ + unansweredSiblings: unanswered, + }, + }); } - const vendorRevocationRequested = keyApps.has(input.toolkit) - ? false - : ended; + // Then the apps that went with it, each naming the disconnect that ended it, by the three + // rules that are written down once; see {@link clearEndedSiblings}. + await clearEndedSiblings({ + siblings, + account: input.account, + endedWith: input.toolkit, + revoked: ended, + unanswered, + by: input.by, + reason: input.reason, + }); + return { vendorRevocationRequested }; }, @@ -8205,10 +8354,14 @@ export function createPluginStore(options: PluginStoreOptions) { * ANSWER TRUE. Called on anything less, it would be claiming an account gone on the strength * of an answer nobody got; that rule is {@link removeServer}'s and this one is its other half. * - * ONLY A ROW THAT WAS ACTUALLY HERE GETS A TRAIL ROW (`if (!deleted) continue`), which is - * again {@link removeServer}'s rule. The act itself is already recorded — on the attempt row, - * as `withdrawn` — so these rows exist to explain why an app's connection VANISHED from this - * deployment's records, and an app that had no row here had nothing to explain. + * ONLY A ROW THAT WAS ACTUALLY HERE GETS A TRAIL ROW, which is again {@link removeServer}'s + * rule. The act itself is already recorded — on the attempt row, as `withdrawn` — so these + * rows exist to explain why an app's connection VANISHED from this deployment's records, and + * an app that had no row here had nothing to explain. + * + * THAT RULE AND THE TWO BESIDE IT NOW LIVE IN {@link clearEndedSiblings}, which this method + * hands its siblings to and which {@link disconnectBrokered} hands its own to. They were + * spelled twice and the two spellings disagreed about every one of them. * * `reason` IS ONE WORD AND IT IS THIS ONE. "The consent's context moved on" is not any of the * four {@link disconnectBrokered} takes — nobody disconnected anything, nobody was offboarded, @@ -8232,56 +8385,62 @@ export function createPluginStore(options: PluginStoreOptions) { }): Promise<{ cleared: string[] }> { const cleared: string[] = []; /* - * The asked app first, then the apps that went with it, so the trail reads in the order the + * THE ASKED APP FIRST, then the apps that went with it, so the trail reads in the order the * withdrawal happened. The asked app's own row names no other app beside it — it was ended * by its own ask — and a sibling's names the app that asked, which is the only thing that * tells a reader why a connection they never touched is gone. + * + * THE ASKED APP TAKES THE SAME THREE RULES, which is this method's whole difference from + * {@link disconnectBrokered}'s asked app and is right for the same reason the rules are: + * nobody here pressed anything about this app either. A caller's revoke ended an OAuth + * connection for a RETIRED identity, so an app this deployment holds a key for was not in + * it (rule 1); the caller watched that revoke answer true, which is the contract above and + * is rule 2 discharged at the door; and a row that was not here had nothing to explain + * (rule 3). The act itself is already recorded on the attempt row, as `withdrawn`. */ - for (const app of new Set([input.toolkit, ...input.siblings])) { - /* - * AN APP THIS DEPLOYMENT HOLDS A KEY FOR IS NOT WHAT THAT REVOKE ENDED, so its row stands - * and the trail says nothing about it. The withdrawal ended an OAuth connection; a key - * sits in this deployment's own records and no revoke at the vendor reaches it. Deleting - * it would destroy a credential nothing withdrew, and recording it would claim a vendor - * ended something it never held — which is the reading {@link disconnectBrokered} writes - * `vendorRevocationRequested: false` for, there on an act that really was about that app. - * The scheme is read off the row while the row is still here, and it is a local read. - */ - if ((await brokeredAppKind(app)) === "key") continue; + if ((await brokeredAppKind(input.toolkit)) !== "key") { const [deleted] = await database .delete(brokeredConnections) - .where(accountRow(app, input.account)) + .where(accountRow(input.toolkit, input.account)) .returning({ app: brokeredConnections.app }); - if (!deleted) continue; - cleared.push(app); - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: app, - payload: { - actor: input.by, - server: app, - owner: - input.account.holder === "person" - ? input.account.userId - : "deployment", - reason: input.reason, - /* - * TRUE FOR EVERY ROW HERE, AND IT IS NOT A GUESS: this method is reached only from a - * revoke that answered true, and on Arcade that ask ended the siblings with the app it - * named. `vendorRevocationRequested: false` means "no vendor was asked", which is the - * one thing none of these rows may say. - */ - vendorRevocationRequested: true, - ...(app === input.toolkit - ? {} - : { - endedWith: input.toolkit, - unansweredSiblings: input.unanswered, - }), - }, - }); + if (deleted) { + cleared.push(input.toolkit); + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + server: input.toolkit, + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + /* + * TRUE FOR EVERY ROW HERE, AND IT IS NOT A GUESS: this method is reached only from a + * revoke that answered true, and on Arcade that ask ended the siblings with the app + * it named. `vendorRevocationRequested: false` means "no vendor was asked", which is + * the one thing none of these rows may say. + */ + vendorRevocationRequested: true, + }, + }); + } } + + cleared.push( + ...(await clearEndedSiblings({ + siblings: input.siblings, + account: input.account, + endedWith: input.toolkit, + // The contract above: the caller watched its revoke answer true before calling this. + revoked: true, + unanswered: input.unanswered, + by: input.by, + reason: input.reason, + })), + ); return { cleared }; }, diff --git a/server/tests/arcade-disconnect-cascade.test.ts b/server/tests/arcade-disconnect-cascade.test.ts index 785f2b253..e0ad48e5e 100644 --- a/server/tests/arcade-disconnect-cascade.test.ts +++ b/server/tests/arcade-disconnect-cascade.test.ts @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { createAuditStore } from "../src/audit"; import { createDatabase } from "../src/db/client"; -import { auditEvents, brokeredConnections } from "../src/db/schema"; +import { auditEvents, brokeredConnections, mcpServers } from "../src/db/schema"; import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; @@ -23,6 +23,7 @@ const calendar = `GoogleCalendar-${suite}`; const slack = `Slack-${suite}`; const drive = `GoogleDrive-${suite}`; const lonely = `Lonely-${suite}`; +const keyed = `Keyed-${suite}`; const providerOf: Record = { [gmail]: "google", @@ -30,11 +31,18 @@ const providerOf: Record = { [drive]: null, // the resolver cannot answer for this one [slack]: "slack", [lonely]: "google", + // Gmail's connection exactly, except that this deployment holds a KEY for it. + [keyed]: "google", }; /** What the vendor was asked to revoke, in order, and whether it should refuse. */ const revoked: string[] = []; let refuseRevoke = false; +/* + * What `revoke` ANSWERS where it does not refuse. `false` is "there was nothing to withdraw", and + * it is the answer no app but the asked one may be claimed gone on. + */ +let revokeAnswer = true; const broker: ConnectedAppBroker = { grantIsLocal: false, @@ -60,7 +68,7 @@ const broker: ConnectedAppBroker = { revoked.push(toolkit); if (refuseRevoke) throw new Error("Arcade revoke failed for 1 of 2 connections."); - return true; + return revokeAnswer; }, connectionFields: async () => [], connectWithFields: async () => ({ accountId: "ca_1" }), @@ -89,6 +97,36 @@ async function hold(app: string, userId: string) { vendorUserId: userId, }); } +/* + * A row under the DEPLOYMENT's holder but on a person's vendor identity: `resolveSiblings` reads + * the identity and so finds it, and `accountRow` reads the holder and so does not touch it. + */ +async function holdForDeployment(app: string, vendorUserId: string) { + seeded.push(app); + await database.insert(brokeredConnections).values({ + provider: "arcade", + app, + holder: "deployment", + userId: null, + vendorUserId, + }); +} +/* + * The app's own row, which is where its auth scheme is recorded and so the only thing that says + * whether this deployment holds a key for it. + */ +const servers: string[] = []; +async function server(app: string, authScheme: string) { + servers.push(app); + await database.insert(mcpServers).values({ + id: app, + title: app, + vendor: "Arcade", + url: `arcade://${app}`, + provenance: "arcade", + authScheme, + }); +} const rowsFor = async (apps: string[]) => ( await database @@ -121,8 +159,13 @@ afterEach(async () => { .where(inArray(brokeredConnections.app, seeded)); } seeded.length = 0; + if (servers.length) { + await database.delete(mcpServers).where(inArray(mcpServers.id, servers)); + } + servers.length = 0; revoked.length = 0; refuseRevoke = false; + revokeAnswer = true; }); const disconnect = (userId: string, toolkit: string) => @@ -189,4 +232,50 @@ describe("the disconnect cascade", () => { expect(JSON.stringify(event.payload)).not.toContain(drive); } }); + + /* + * THE THREE RULES A CASCADE DELETE OBEYS, each asserted on the thing they protect: the row of an + * app NOBODY ASKED ABOUT. All three live in `clearEndedSiblings` in `store.ts`. + */ + test("a KEY sibling's row SURVIVES, and nothing is written about it", async () => { + const userId = `person-${suite}-e`; + await server(keyed, "API_KEY"); + await hold(gmail, userId); + await hold(keyed, userId); // same identity, same provider — but a key, not a consent + + await disconnect(userId, gmail); + + // The one OAuth revoke never ended the key, so the row that is the only way to reach that + // account is still here, and the trail claims nothing about it. + expect(await rowsFor([gmail, keyed])).toEqual([keyed]); + expect((await trailFor(userId)).map((event) => event.app)).toEqual([gmail]); + }); + + test("a revoke that ANSWERS FALSE leaves a sibling's row standing and unaudited", async () => { + const userId = `person-${suite}-f`; + await hold(gmail, userId); + await hold(calendar, userId); + revokeAnswer = false; + + await disconnect(userId, gmail); + + // Nothing was shown to have been withdrawn, so no app but the asked one is claimed gone. + expect(await rowsFor([gmail, calendar])).toEqual([calendar]); + const trail = await trailFor(userId); + expect(trail.map((event) => event.app)).toEqual([gmail]); + expect(trail[0]?.payload.vendorRevocationRequested).toBe(false); + }); + + test("a sibling this account holds NO ROW for gets no trail row", async () => { + const userId = `person-${suite}-g`; + await hold(gmail, userId); + await holdForDeployment(calendar, userId); // a sibling, but not this account's row + + await disconnect(userId, gmail); + + // The row is another holder's and so was never this disconnect's to delete — and a row that + // was not deleted has no vanished connection to explain. + expect(await rowsFor([gmail, calendar])).toEqual([calendar]); + expect((await trailFor(userId)).map((event) => event.app)).toEqual([gmail]); + }); }); diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 92de055c1..e3b78964c 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -898,7 +898,9 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/arcade-disconnect-cascade.test.ts", - line: 36, + // Moved from 36 by round 2's A5 fix, which added three tests above it; the offence is the + // same one and is re-keyed rather than re-found. + line: 39, shape: "e", finding: "round 2 — arcade-disconnect-cascade.test.ts:36,60", why: "const revoked: string[] = [];", From 3d029bbb1d4ce3dd5b57712d146e9b51cc15e0e4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:48:45 +0200 Subject: [PATCH 177/232] fix(app): drop every consumed one-shot parameter from Connected accounts, not just ?linked= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connected accounts accepts three one-shot report parameters and stripped one of them. `?connected=failed` (the Composio OAuth callback's failure report) and `?authorized=` (the brokered per-action verifier's refusal code) stayed in the address bar after their alert was drawn, so both replayed on reload and on back-navigation: somebody who reloads is told again that their account could not be connected, or that there is no authorization in progress, about an attempt they have since finished. `?linked=` was already read into state and stripped; the other two were read live off the search and never cleaned. Both are now read once into the same `arrival` snapshot the `?linked=` notice already used, and ONE effect drops all three keys in a single navigate. One navigate rather than three: `search` replaces the whole object, so per-parameter effects race and a later one rebuilds the key an earlier one had just removed. The snapshot is what makes the unconditional strip safe — read live, each notice would vanish the instant its parameter did. Confirmed these are all of them: `validateSearch` on this route accepts exactly `connected`, `authorized`, `linked` and nothing else (unknown keys are dropped by the parser), and the only senders are `server/src/plugins/oauth.ts:157` (`?connected=failed`), `server/src/plugins/routes.ts:3073` (`?authorized=`) and `server/src/identity/github-callback.ts:63` (`?linked=`). There is no third. Call sites of what changed — all inside `RouteComponent` in `app/src/routes/_authed/settings/connected-accounts/index.tsx`; no exported symbol's signature or behaviour changed, so nothing outside this file is affected: - the `?connected=` read, now `arrival.outcome` at the failure alert - the `?authorized=` read, now `arrival.authorizationNotice` at its alert - `visibleLinkedNotice(arrival.linked, links.data)`, renamed field only - the strip effect, now keyed on all three values Red-green: `app/tests/connected-accounts-oneshot-params.test.tsx` run against the base copy of the production file (076677e8) fails 3 of 5 with the search left as `{connected: "failed"}`, `{authorized: "no-attempt"}` and both together — while `linked` is already gone, which is the defect exactly. With the fix restored, 5 pass. A planted variant that strips the parameters WITHOUT snapshotting them reddens the other half of each assertion ("the sentence survives the strip", `expect(received).not.toBeNull()` at lines 84, 94, 122); plant removed, tree clean. Reported, not fixed (another owner's call): `?action=` on `app/src/routes/_authed/settings/connected-accounts/$key.tsx` is the same omission — the verifier's landing parameter is drawn as "You just authorized X" and never stripped, so it replays on reload. It is not a pure one-shot, though: `landedAction` is also handed to `GrantList`, which marks the matching row with `data-landed`, so stripping it would also drop that mark. The sibling admin screens under `app/src/routes/_authed/admin/plugins/` read no search parameters at all, so there is nothing of this kind there. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/connected-accounts/index.tsx | 52 +++++-- ...connected-accounts-oneshot-params.test.tsx | 133 ++++++++++++++++++ 2 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 app/tests/connected-accounts-oneshot-params.test.tsx diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index ef9241008..649dc812f 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -243,18 +243,50 @@ export function connectedAccountSections( function RouteComponent() { const { connected: outcome, authorized, linked } = Route.useSearch(); - const authorizationNotice = authorizationNoticeFor(authorized); - /* Read once, then dropped from the URL, so a refresh does not repeat the news. */ - const [arrival] = React.useState(() => linkedNotice(linked)); + /* + * ALL THREE READ ONCE HERE, AND ALL THREE DROPPED FROM THE URL BELOW — not just `?linked=`. + * + * Every parameter this screen accepts is a report of something that has already happened: the + * Composio callback's `?connected=`, the brokered verifier's `?authorized=` and the GitHub + * account-link callback's `?linked=`. A report is news exactly once. Left in the address bar it + * is read again on every reload and on every back-navigation, so a failure that is minutes old + * is redrawn as though it had just happened — somebody is told again that their account could + * not be connected, or that there is no authorization in progress, about an attempt they have + * since finished successfully. Only `?linked=` was ever cleaned, which is how the other two came + * to replay. + * + * THE SNAPSHOT IS WHAT THE PAGE DRAWS FROM, and it is why the strip can be unconditional. Read + * live off the search, the notice would vanish the instant the parameter did; held here, the + * sentence outlives the URL it arrived in. That is what `?linked=` was already doing, and the + * other two now do it for the same reason rather than by accident. + */ + const [arrival] = React.useState(() => ({ + outcome, + authorizationNotice: authorizationNoticeFor(authorized), + linked: linkedNotice(linked), + })); /* The same cached list the Linked accounts section reads, so a Disconnect there retracts a success here. */ const links = useQuery(linkedAccountsQueryOptions()); - const notice = visibleLinkedNotice(arrival, links.data); + const notice = visibleLinkedNotice(arrival.linked, links.data); const navigate = Route.useNavigate(); + /* + * ONE NAVIGATE FOR ALL THREE, never one per parameter. `search` replaces the whole object, so two + * effects each dropping their own key race: whichever lands second rebuilds from a snapshot taken + * before the first, and puts the key the first had just removed back. + */ React.useEffect(() => { - if (linked !== undefined) { - navigate({ search: ({ linked: _l, ...rest }) => rest, replace: true }); + if ( + outcome === undefined && + authorized === undefined && + linked === undefined + ) { + return; } - }, [linked, navigate]); + navigate({ + search: ({ connected: _c, authorized: _a, linked: _l, ...rest }) => rest, + replace: true, + }); + }, [outcome, authorized, linked, navigate]); const [search, setSearch] = React.useState(""); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); @@ -359,7 +391,7 @@ function RouteComponent() { * came back to now reads "Connected", which is the same news told by the thing it is news * about. `?linked=` is different and says both outcomes, through `linkedNotice` below. */} - {outcome === "failed" ? ( + {arrival.outcome === "failed" ? (

That account could not be connected. Nothing was saved — try again.

@@ -380,9 +412,9 @@ function RouteComponent() { * because three of its refusals have no app to name, and a notice that sometimes appears one * level in and sometimes here would be a notice people learn to look for in two places. */} - {authorizationNotice ? ( + {arrival.authorizationNotice ? (

- {authorizationNotice} + {arrival.authorizationNotice}

) : null} {/* diff --git a/app/tests/connected-accounts-oneshot-params.test.tsx b/app/tests/connected-accounts-oneshot-params.test.tsx new file mode 100644 index 000000000..a3b28280e --- /dev/null +++ b/app/tests/connected-accounts-oneshot-params.test.tsx @@ -0,0 +1,133 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { identityKeys } from "@/lib/identity/queries"; +import { pluginKeys } from "@/lib/plugins/queries"; +import { Route } from "@/routes/_authed/settings/connected-accounts/index"; + +/** + * EVERY ONE-SHOT PARAMETER THIS SCREEN CONSUMES IS DROPPED FROM THE URL, NOT JUST `?linked=`. + * + * All three — `?connected=failed` from the Composio OAuth callback, `?authorized=` from the + * brokered per-action verifier, `?linked=` from the GitHub account-link callback — report something + * that has already happened: read once, and then over. A parameter left in the address bar is read + * again on every reload and on every back-navigation, so news that is minutes old is redrawn as + * though it had just happened — somebody is told again that their account could not be connected, + * or that there is no authorization in progress, about an attempt they have since finished. + * `?linked=` was stripped and the other two were not. + * + * THE NOTICE STILL HAS TO BE DRAWN, which is the other half of the claim and the reason the strip + * cannot be a bare navigate on mount: the screen reads each value once into state, draws the + * sentence from that, and only the URL is cleaned. A test asserting an empty URL alone would be + * satisfied by a screen that says nothing at all. + */ + +const PATH = "/settings/connected-accounts"; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + // Let React's scheduler drain before the DOM globals it reads go away. + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); + +type TestRouter = { state: { location: { search: unknown; href: string } } }; + +function renderScreen(search: string) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // Seeded rather than fetched: this screen's data is not what is under test here. + queryClient.setQueryData(pluginKeys.page(), { servers: [], catalogue: [] }); + queryClient.setQueryData(pluginKeys.connections(), { connections: [] }); + queryClient.setQueryData(identityKeys.links(), []); + + const root = createRootRoute({ component: () => }); + const screen = Route.update({ + id: PATH, + path: PATH, + getParentRoute: () => root, + } as never); + // The route tree is assembled by hand, so its types cannot describe it. + const router = createRouter({ + routeTree: root.addChildren([screen]), + context: { queryClient }, + history: createMemoryHistory({ initialEntries: [`${PATH}${search}`] }), + } as never) as never; + const view = render( + + + , + ); + return { view, router: router as TestRouter }; +} + +/** What the address bar holds now, as the router sees it. */ +const searchOf = (router: TestRouter) => router.state.location.search; + +test("?connected=failed is said once and then dropped from the URL", async () => { + const { view, router } = renderScreen("?connected=failed"); + await view.findByText(/That account could not be connected/); + await waitFor(() => expect(searchOf(router)).toEqual({})); + // And the sentence survives the strip: the news is read once, not withdrawn. + expect( + view.queryByText(/That account could not be connected/), + ).not.toBeNull(); + expect(router.state.location.href).toBe(PATH); +}); + +test("?authorized= is said once and then dropped from the URL", async () => { + const { view, router } = renderScreen("?authorized=no-attempt"); + await view.findByText(/There is no authorization in progress/); + await waitFor(() => expect(searchOf(router)).toEqual({})); + expect( + view.queryByText(/There is no authorization in progress/), + ).not.toBeNull(); + expect(router.state.location.href).toBe(PATH); +}); + +/** The one that was already stripped, kept here so the three are pinned in one place. */ +test("?linked= is said once and then dropped from the URL", async () => { + const { view, router } = renderScreen("?linked=failed"); + await view.findByText(/Your GitHub account could not be linked/); + await waitFor(() => expect(searchOf(router)).toEqual({})); + expect( + view.queryByText(/Your GitHub account could not be linked/), + ).not.toBeNull(); +}); + +/** + * ALL THREE AT ONCE, because stripping them one at a time is the failure mode a per-parameter + * effect invites: each navigate replaces the whole search, so one that drops `connected` while + * rebuilding `authorized` leaves the second behind for ever. + */ +test("all three arriving together leave nothing behind", async () => { + const { view, router } = renderScreen( + "?connected=failed&authorized=no-attempt&linked=failed", + ); + await view.findByText(/That account could not be connected/); + await waitFor(() => expect(searchOf(router)).toEqual({})); + expect(router.state.location.href).toBe(PATH); + expect( + view.queryByText(/There is no authorization in progress/), + ).not.toBeNull(); + expect( + view.queryByText(/Your GitHub account could not be linked/), + ).not.toBeNull(); +}); + +/** Nothing to consume means nothing to clean up, and no navigation at all. */ +test("an arrival with no one-shot parameter is left exactly as it came", async () => { + const { router } = renderScreen(""); + await waitFor(() => expect(searchOf(router)).toEqual({})); + expect(router.state.location.href).toBe(PATH); +}); From 046f05ab2b10de3902c2071770f9a8bf5ccd33a5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:55:37 +0200 Subject: [PATCH 178/232] fix(plugins): tell the truth about a completion /arcade/verify could not report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier's blanket `catch` answered `not-recorded` for every throw inside the handler and logged nothing at all. Both halves of that were wrong at once on the one leg where a vendor grant can be LIVE with nothing here recording it. THE FALSE NEGATIVE. `completeAuthorizationAttempt`'s transaction is all-or-nothing, but "the call rejected" and "the work rolled back" are different facts: a connection lost on the way back from a COMMIT the server has already applied rejects at the client over a grant that IS written down. The person was then told it was not recorded, so they start over and mint a second consent at the vendor against a connection they already hold — the exact sentence this leg exists to get right, backwards. The completion now has its own `catch`, and on a throw it ASKS the row the transaction serializes on instead of reading the throw as a verdict. A probe that cannot be reached reads as not recorded, which is the safe direction: the completion is idempotent on `completed_at IS NULL`, so a retry over a grant that did land writes nothing twice. THE SILENCE. Every sibling on this route reports a failure it swallowed; this one reported none. Two lines now: - `arcade-completion-not-recorded` / `arcade-completion-unacknowledged`, carrying the authorization id, the app and the action. - `arcade-verify-failed` from the blanket catch, carrying `recorded` so which side of the completion a throw came from is checkable in a deployment. WHAT IS LOGGED AND WHY IT IS SAFE (Claim 2). The `ar_…` is the same handle `arcade-mint-not-recorded` already carries at the other end of this lifecycle, and it is what lets an operator find the flow at the vendor and end it; the app and the action are this deployment's own catalogue names. NOT the bearer `authorization.url` — this handler never holds one. NOT the status reply, which carries a live provider access token and the person's profile behind D31's narrowing, nor the scopes or vendor user id read out of it. The error goes through `reasonWithoutStatement`, because the failure these lines exist for is a `DrizzleQueryError` whose own message is the statement and every value bound to it — which on this statement includes the scope list and the account. The landing URL is now built before the write rather than after it, so the only step between a committed grant and the browser is the redirect itself. NEW STORE METHOD AND ITS CALL SITES. `PluginStore.authorizationAttemptCompleted ({ actorId, authId })` reads `outcome = 'completed'` off `pending_authorizations`. One caller: the completion's `catch` in `/arcade/verify` (server/src/plugins/routes.ts). It is deliberately NOT `authorizationAttemptFor`, which filters on `completed_at IS NULL` and on the fifteen-minute TTL and so answers `null` for a completed attempt and an expired one alike. Nothing else changed signature; `completeAuthorizationAttempt` has the same one caller it had. TESTS (server/tests/arcade-verify.test.ts, four new cases under "a completion the deployment could not report"). A `completion` build option injects a completion that rolls back and one that COMMITS AND THEN THROWS — no broken `database` handle can pose the second, because a handle that breaks the transaction rolls it back. All four were watched failing against the base `routes.ts`/`store.ts`: the committed-then-threw case answered `?authorized=not-recorded` where it must answer the landing, and the three log assertions found no line. The injected failure is shaped as drizzle shapes one — own `query`/`params`, the suite's live token and the person's address in its message, the driver's complaint on `cause` — and swapping `reasonWithoutStatement(error)` for `String(error)` was watched printing the token into the log line. Not fixed, reported instead: `composio.broker.revoke`'s blanket catch on the context-changed branch (routes.ts) can lose a vendor-side revoke that succeeded before the deadline fired, and logs nothing; the attempt is marked `unreconciled`, which that branch argues is the operator-actionable row. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/plugins/routes.ts | 137 +++++++++++++++--- server/src/plugins/store.ts | 38 +++++ server/tests/arcade-verify.test.ts | 214 ++++++++++++++++++++++++++++- 3 files changed, 370 insertions(+), 19 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 8871afbe4..b971746c9 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3079,6 +3079,15 @@ export function createPluginRoutes( */ const flowId = (context.req.query("flow_id") ?? "").trim(); + /** + * WHETHER THE COMPLETION TRANSACTION IS KNOWN TO HAVE COMMITTED, and the one thing the two + * `catch`es below have to agree about. Declared out here, ahead of the `try`, because the + * blanket `catch` has to be able to tell a throw over a live, recorded grant from a throw over + * nothing at all — which is the distinction it used to answer `not-recorded` for both halves + * of. Set in exactly one place: the completion's own `catch` below. + */ + let recorded = false; + /* * EVERYTHING FROM HERE IS IN ONE `try`, FOR THE REASON `/oauth/callback` BELOW GIVES FOR ITS * OWN: so it is the whole body rather than each call. A blanket `catch` is a promise about the @@ -3097,9 +3106,10 @@ export function createPluginRoutes( * THE STATED EXCEPTION IS UNTOUCHED BY THIS. The 403 for a session with no role RETURNS, and a * `catch` sees throws and not returns, so it keeps its JSON body. * - * The inner `try`s below take the vendor's throws first, so no refusal code changes here. The - * completion transaction is all-or-nothing, so a throw out of it has written neither the - * connection row nor its audit entry. + * The inner `try`s below take the vendor's throws first, so no refusal code changes here. AND + * THE COMPLETION NOW HAS ONE OF ITS OWN, which is not a hedge against this `catch` but the + * thing this `catch` cannot do: it is the only step on this leg that can leave a grant RECORDED + * on the way out, so it is the only one that can say whether `not-recorded` is true. See it. */ try { let signedIn = false; @@ -3390,15 +3400,6 @@ export function createPluginRoutes( const authProvider = await withVendorDeadline(() => composio.broker.authProviderOf(fresh.toolkit), ).catch(() => null); - await store.completeAuthorizationAttempt({ - actorId, - authId, - toolkit: fresh.toolkit, - account: fresh.account, - action: attempt.action, - scopes: settled.scopes, - authProvider: authProvider ?? "", - }); /* * AND THE LANDING COMES FROM THE ATTEMPT ROW (D18), NEVER FROM `next_uri`. The vendor refuses @@ -3416,14 +3417,114 @@ export function createPluginRoutes( * * `connectedAccountsUrlFor` with `{ serverId }` answers a query-less path, so appending * `?action=` here cannot produce a second `?`. + * + * BUILT BEFORE THE WRITE RATHER THAN AFTER IT, so that the only thing standing between a + * committed grant and the person's browser is the redirect itself. Both reads are off the + * attempt row and neither calls anybody. */ - return context.redirect( - `${connectedAccountsUrlFor(connect?.appUrl, { - serverId: attempt.serverId, - })}?action=${encodeURIComponent(attempt.action)}`, - 303, + const landing = `${connectedAccountsUrlFor(connect?.appUrl, { + serverId: attempt.serverId, + })}?action=${encodeURIComponent(attempt.action)}`; + + /* + * THE COMPLETION HAS ITS OWN `catch`, AND IT IS THE ONLY PLACE ON THIS LEG THAT MAY ANSWER + * `not-recorded`. + * + * THE DEFECT IT CLOSES, FIRST HALF: the blanket `catch` below used to answer `not-recorded` + * for every throw in this handler INCLUDING ONE RAISED AFTER THIS TRANSACTION HAD COMMITTED. + * The grant is live at the vendor and the row is written here, and the person was told it was + * not recorded — so they start over, mint a second consent against a connection they already + * have, and the one sentence this leg exists to get right is the one it got backwards. + * + * THE TWO ARE NOT TOLD APART BY WHETHER THE CALL THREW. The transaction is all-or-nothing + * ({@link PluginStore.completeAuthorizationAttempt}), but "the call rejected" and "the work + * rolled back" are different facts: a connection lost on the way back from a COMMIT the + * server already applied rejects here over a grant that IS written down. So the row the + * transaction serializes on is ASKED + * ({@link PluginStore.authorizationAttemptCompleted}) rather than inferred from the throw, + * and that answer — not the throw — decides which sentence the person gets. A probe that + * cannot be reached either reads as not recorded, which is the reading that sends somebody + * back to try again rather than away believing in a grant that is not there; the completion + * is idempotent on `completed_at IS NULL`, so a retry over a grant that did land writes + * nothing twice. + * + * SECOND HALF: IT IS LOGGED, which the blanket `catch` did not do at all. This is the one leg + * where a vendor grant can be LIVE with nothing here recording it, and it was also the one + * leg that left an operator no trace of that happening — every sibling on this route logs, + * and this is the same line `arcade-mint-not-recorded` writes at the other end of the same + * lifecycle, for the same reason: the `ar_…` is what lets an operator find that flow at the + * vendor and end it. + * + * WHAT IS IN THE LINE IS EXACTLY WHAT IS SAFE TO PUT THERE. The authorization id, the app and + * the action: the first is the handle the mint log already carries, and the other two are + * this deployment's own catalogue names. NOT the `authorization.url`, which is the bearer + * capability whoever opens it attaches the account with and which this handler never holds a + * copy of anyway; NOT the status reply, which carries a LIVE PROVIDER ACCESS TOKEN and the + * person's profile behind D31's narrowing; NOT the granted scopes and NOT the vendor user id, + * which name the person and buy an operator nothing the id does not. The error goes through + * {@link reasonWithoutStatement}, because a `DrizzleQueryError`'s own message is the failed + * statement and every value bound to it — which here includes the scope list and the account. + */ + try { + await store.completeAuthorizationAttempt({ + actorId, + authId, + toolkit: fresh.toolkit, + account: fresh.account, + action: attempt.action, + scopes: settled.scopes, + authProvider: authProvider ?? "", + }); + recorded = true; + } catch (error) { + recorded = await store + .authorizationAttemptCompleted({ actorId, authId }) + .catch(() => false); + console.error( + JSON.stringify({ + type: recorded + ? "arcade-completion-unacknowledged" + : "arcade-completion-not-recorded", + server: attempt.serverId, + action: attempt.action, + authorizationId: authId, + note: recorded + ? "A per-action consent was written down and the write then failed to report itself. The attempt row says it committed, so the person was sent on to the page that says so and nothing needs redoing." + : "A person completed a per-action consent at the vendor and this deployment could not write it down. The authorization named here is LIVE at the vendor with nothing here recording it; the person was told so, and the attempt row stands until its TTL.", + error: reasonWithoutStatement(error), + }), + ); + if (!recorded) return refusedWith("not-recorded"); + } + + return context.redirect(landing, 303); + } catch (error) { + /* + * AND THE BLANKET `catch` IS NO LONGER WHERE A LOST COMPLETION GOES QUIET. It stands over the + * reads that happen BEFORE the completion — the guard's session and role reads, the attempt + * read, and the refusals' own string building — so `not-recorded` is true of everything that + * reaches it: the completion above answers for itself, under its own `catch`, and never falls + * through to here still thinking it might have landed. + * + * `recorded` IS LOGGED RATHER THAN ASSUMED FALSE. The only step left after the completion is + * the redirect, and the sole way it can throw is a `Location` this deployment's own `appUrl` + * made unencodable — which would equally defeat the refusal built below, so there is no + * honest second answer to give here and the flag is what makes the claim checkable in a + * deployment rather than only in this comment. + * + * LOGGED AT ALL, which is the second half of what this leg was missing: every sibling on this + * route reports a failure it swallowed, and this one reported nothing. With the same + * restraint as the completion's own line above and nothing of the vendor's in it, through + * {@link reasonWithoutStatement} for the reason given there. + */ + console.error( + JSON.stringify({ + type: "arcade-verify-failed", + recorded, + note: "A check on the verifier raised instead of answering. The person was sent back to the list with a failure.", + error: reasonWithoutStatement(error), + }), ); - } catch { return refusedWith("not-recorded"); } }); diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e165d753a..0a24dc1b4 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -6744,6 +6744,44 @@ export function createPluginStore(options: PluginStoreOptions) { * The live attempt for a session subject (`users.id`, never `credentialActorId`), or null when * there is none, it is past the 15-minute TTL, or it has completed. */ + /** + * Whether this attempt's completion COMMITTED — asked of the row the transaction serializes on. + * + * FOR ONE CALLER AND ONE QUESTION: `/arcade/verify`'s completion rejected, and "the call threw" + * is not "the work rolled back". A connection lost on the way back from a COMMIT the server had + * already applied rejects at the client over a grant that is written down, and the verifier + * telling that person `not-recorded` sends them to mint a second consent at the vendor against + * a connection they already hold. The attempt row is the one place that can say which happened: + * {@link createPluginStore}'s `completeAuthorizationAttempt` stamps `outcome = 'completed'` in + * the first statement of the transaction, so the stamp exists exactly when the transaction did. + * + * NOT {@link authorizationAttemptFor}, which cannot answer this. It filters on `completed_at IS + * NULL` AND on the fifteen-minute TTL, so it answers `null` for a completed attempt and for an + * expired one alike — and a verifier that read "no live attempt" as "it committed" would + * report a grant that is not there for any completion that ran long enough to age out. + * + * MATCHED ON `(actor_id, auth_id)` like the closers above and never on `created_at`, because + * what the caller holds at this point is the `ar_...`. A rival callback having completed the + * same attempt first answers `true` here, which is correct: the grant is recorded either way, + * and this deployment has never been able or willing to tell the winner from the loser. + */ + async authorizationAttemptCompleted(input: { + actorId: string; + authId: string; + }): Promise { + const [row] = await database + .select({ actorId: pendingAuthorizations.actorId }) + .from(pendingAuthorizations) + .where( + and( + eq(pendingAuthorizations.actorId, input.actorId), + eq(pendingAuthorizations.authId, input.authId), + eq(pendingAuthorizations.outcome, "completed"), + ), + ); + return row !== undefined; + }, + async authorizationAttemptFor( actorId: string, ): Promise { diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 9e0ba5d17..0c9a13964 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -178,6 +178,17 @@ function build( * the only way to ask is to put the row in its hand. */ attemptAs?: { holder?: string; userId?: string | null }; + /** + * What the COMPLETION does, and the two values are not the same failure. + * + * `rolls-back` throws instead of writing — the transaction never ran, so nothing exists. + * `commits-then-throws` runs the real transaction to completion and THEN rejects, which is a + * connection lost on the way back from a COMMIT the server already applied: the grant is + * written down and the caller is told only that something failed. The route may not answer + * those two the same way, and no broken `database` handle can pose the second — a handle that + * breaks the transaction rolls it back, which is the first. + */ + completion?: "rolls-back" | "commits-then-throws"; } = {}, ) { const handle = options.database ?? database; @@ -229,7 +240,21 @@ function build( : ({ ...row, ...options.attemptAs } as typeof row); }, } - : pluginStore; + : options.completion + ? { + ...pluginStore, + completeAuthorizationAttempt: async ( + input: Parameters< + typeof pluginStore.completeAuthorizationAttempt + >[0], + ) => { + if (options.completion === "commits-then-throws") { + await pluginStore.completeAuthorizationAttempt(input); + } + throw completionFailure(); + }, + } + : pluginStore; args[29] = "brokerBag" in options ? options.brokerBag : { broker, provider: "arcade" }; return createApp(...args); @@ -1458,6 +1483,193 @@ describe("a return leg that completes", () => { }); }); +/** + * THE THROW A COMPLETION REALLY FAILS WITH, SHAPED AS DRIZZLE SHAPES IT. + * + * Not a bare `new Error("boom")`, because the thing the log line has to be careful about is exactly + * what a bare error does not carry. drizzle wraps a failed statement as an error whose OWN message + * is `Failed query: ` plus `params: `, with the driver's + * complaint on `cause` — and the values bound by this particular statement are the granted scopes + * and the account. This one carries the suite's live access token and the person's address in the + * same places, so a log line that quoted `.message` would print both, and `reasonWithoutStatement` + * is the only reason it does not. + */ +function completionFailure(): Error { + return Object.assign( + new Error( + `Failed query: insert into "brokered_connections" ... params: ${TOKEN}, ${EMAIL}`, + ), + { + query: 'insert into "brokered_connections" ...', + params: [TOKEN, EMAIL], + cause: new Error("canceling statement due to statement timeout"), + }, + ); +} + +/** + * Everything the deployment said while the request ran, AS THE ARGUMENTS THEMSELVES. + * + * Not `args.map(String).join(" ")`, for the reason `harvest` gives at length: a value logged inside + * an object flattens to `[object Object]`, which is the shape a leak hides in. + */ +async function heard(work: () => T | Promise): Promise<[T, unknown[][]]> { + const said: unknown[][] = []; + const before = globalThis.console; + const hear = (...args: unknown[]) => said.push(args); + globalThis.console = { + ...before, + log: hear, + info: hear, + warn: hear, + error: hear, + debug: hear, + } as typeof console; + try { + return [await work(), said]; + } finally { + globalThis.console = before; + } +} + +/** Every line the run produced, as the JSON each one is written as. */ +const lines = (said: unknown[][]): string[] => + said.map((args) => args.map((arg) => String(arg)).join(" ")); + +/** + * A COMPLETION THAT COULD NOT REPORT ITSELF, WHICH IS TWO DIFFERENT FACTS AND WAS ONE ANSWER. + * + * This leg is the only one in this file where a grant can be LIVE AT THE VENDOR while this + * deployment holds nothing — the person consented at the vendor before anything here was asked. + * The handler's blanket `catch` answered `not-recorded` for every throw inside it and logged + * nothing at all, so the two halves of that were both wrong at once: a completion that HAD + * committed was reported to the person as not recorded, and a completion that had genuinely been + * lost left an operator no trace to act on. + */ +describe("a completion the deployment could not report", () => { + test("one that rolled back refuses as not-recorded AND leaves an operator a line", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + + const [response, said] = await heard(() => + verify(ACTOR, "?flow_id=flow-abc", { completion: "rolls-back" }), + ); + + await expectRefusal(response, "not-recorded"); + // The sentence is true, and these two are what make it so. + await expectNothingWritten(); + const row = await attemptRow(); + expect(row?.completedAt).toBeNull(); + + /* + * AND IT WAS SAID OUT LOUD. The vendor's grant is live with nothing here recording it, and the + * `ar_…` is what lets an operator find that flow at the vendor and end it — the same thing + * `arcade-mint-not-recorded` carries at the other end of this lifecycle. + */ + const line = lines(said).find((text) => + text.includes("arcade-completion-not-recorded"), + ); + expect(line).toBeDefined(); + expect(line).toContain(MINTED); + expect(line).toContain(ACTION); + expect(line).toContain(APP); + // The driver's complaint, which is the useful half and names nothing anybody sent. + expect(line).toContain("canceling statement due to statement timeout"); + }); + + /** + * AND NOT ONE WORD OF THE VENDOR'S IN IT. The error this completion fails with carries the live + * access token and the person's address where drizzle puts them, which is the one shape this + * route's logging has to survive; the status reply behind it carries them too. + */ + test("the line it leaves carries no token, no address and no scope list", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + statusAnswer = async () => + ({ + status: "completed", + userId: ACTOR, + scopes: ["gmail.read"], + token: TOKEN, + context: { token: TOKEN }, + user_info: { email: EMAIL }, + }) as never; + + const [response, said] = await heard(() => + verify(ACTOR, "?flow_id=flow-abc", { completion: "rolls-back" }), + ); + + await expectRefusal(response, "not-recorded"); + expect( + lines(said).find((text) => + text.includes("arcade-completion-not-recorded"), + ), + ).toBeDefined(); + expect(mentionsOf(said, TOKEN)).toEqual([]); + expect(mentionsOf(said, EMAIL)).toEqual([]); + expect(mentionsOf(await asDelivered(response), TOKEN)).toEqual([]); + }); + + /** + * THE FALSE NEGATIVE, WHICH IS THE HALF THAT COSTS THE PERSON SOMETHING. + * + * The transaction committed and the call rejected anyway — a connection lost on the way back + * from a COMMIT the server had already applied. The grant is live at the vendor AND written down + * here. Told `not-recorded`, this person starts over and mints a second consent at the vendor + * against a connection they already hold; the route has to ask the row the completion serializes + * on instead of reading the throw as a verdict. + */ + test("one that committed and then threw lands the person, and never says not-recorded", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + + const [response, said] = await heard(() => + verify(ACTOR, "?flow_id=flow-abc", { completion: "commits-then-throws" }), + ); + + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe(LANDING); + expect(response.headers.get("location")).not.toContain("authorized="); + + // Because it IS recorded, which is the whole of why the sentence above would have been a lie. + const { connections, events } = await written(); + expect({ connections: connections.length, events: events.length }).toEqual({ + connections: 1, + events: 1, + }); + const row = await attemptRow(); + expect(row?.outcome).toBe("completed"); + + // And an operator is told, because a write that cannot report itself is still worth knowing. + const line = lines(said).find((text) => + text.includes("arcade-completion-unacknowledged"), + ); + expect(line).toBeDefined(); + expect(line).toContain(MINTED); + expect(mentionsOf(said, TOKEN)).toEqual([]); + }); + + /** + * AND THE BLANKET `catch` SPEAKS TOO. It stands over the reads that happen before the completion, + * and it used to swallow every one of them in silence — a `Location` for the person and nothing + * whatever for whoever has to work out why this route started refusing everybody. Its line says + * which side of the completion it came from, because that is the one thing the person's refusal + * code cannot distinguish. + */ + test("a throw before the completion is logged as well as answered", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + + const [response, said] = await heard(() => + verify(ACTOR, "?flow_id=flow-abc", { attemptReadThrows: true }), + ); + + await expectRefusal(response, "not-recorded"); + const line = lines(said).find((text) => + text.includes("arcade-verify-failed"), + ); + expect(line).toBeDefined(); + expect(line).toContain('"recorded":false'); + await expectNothingWritten(); + }); +}); + /** * THE COOKIE POLICY THIS ROUTE DEPENDS ON, AND NOTHING ELSE IN THE SUITE WOULD REPORT IT. * From 538c87d2f36d75e6013da89a8b39abc66cb6af0d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:56:39 +0200 Subject: [PATCH 179/232] Key `pending_authorizations` to the people it names, and index its keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `0057_arcade_provider` created `pending_authorizations` with `actor_id` and `user_id` both documented as `users.id` and neither carrying a foreign key, and with a `server_id` key that postgres does not index on the referencing side. So a deleted person left attempt rows pointing at nobody, an actor id that was never a person was accepted without complaint, and every cascade from `mcp_servers` — plus every lookup by server — scanned the table. A NEW MIGRATION, `0059_pending_authorization_person_keys`, NOT AN EDIT TO `0057`. 0057 has shipped on this branch and anybody who pulled it has applied it; editing it in place would leave their database diverged from a journal that still says it ran. `drizzle-kit check` passes and the journal has one entry per file and one snapshot per entry. NUMBER TAKEN: 0059. Agent B1 in this wave is also adding a migration; if it also took 0059, one of the two renumbers, and both the `.sql`, its `meta/NNNN_snapshot.json` and the `_journal.json` entry move together — `migration-journal.test.ts` fails on any of the three left behind. WHY CASCADE ON BOTH, DELIBERATELY AND NOT BY DEFAULT. An attempt is in-flight working state: a fifteen-minute TTL, at most one row per actor by primary key, holding no grant and no secret. It is NOT the audit trail. What a person authorized is written to `audit_events`, whose `actor_user_id` is keyless ON PURPOSE so the trail outlives them, and `completeAuthorizationAttempt` files that row in the same transaction as the connection. So cascading an attempt away destroys no record of anything. RESTRICT was considered and rejected: it would make a person undeletable for up to fifteen minutes because they happened to have a browser mid-redirect — a legitimate deletion blocked by ephemeral state, and a failure mode an administrator cannot diagnose from the error. THE SIBLING THAT CHOSE DIFFERENTLY, and why it is not a precedent here: `brokered_connections.user_id` is keyless by design, argued at length in `store.ts#retireConnectionsFor` — that row IS the record of which apps somebody held, and offboarding READS IT AFTER the `users` row is gone. Nothing reads an attempt after its person is gone. The other `users`-keyed sibling in this schema file, `mcp_user_credentials.userId`, already cascades. The person-deletion path (`retireConnectionsFor`) does NOT touch `pending_authorizations` today, so before this migration an offboarding left the departed person's in-flight attempt standing. The cascade is now what removes it. EXISTING ORPHANS. `ADD CONSTRAINT` is not conditional, so on a deployment that ran 0057 and has since deleted a person the statement would stop dead. The migration therefore DELETES the orphans first — rows whose `actor_id`, or whose non-null `user_id`, names no `users` row. Deleted rather than repaired: there is nothing to repair them to, and nulling `user_id` would trip `pending_authorizations_holder_check` on a `person` attempt. Such a deployment loses only attempts that could never have completed; its `audit_events` are untouched. CALL SITES. No production call site changed: this is schema only, and `store.ts` writes the same columns it always did. What changed is that every writer of an attempt must now name a real person. The writers are `claimAuthorizationAttempt` (reached from `POST /servers/:id/authorize`), which takes `actorId` from the session, and the tests below, which invented ids. TESTS. `tests/support/people.ts` is new: one place that seeds and removes the `users` rows an attempt needs. Eight suites now use it — `arcade-attempt-schema`, `arcade-attempt-constraints`, `arcade-attempt-store.integration`, `arcade-completion-transaction.integration`, `arcade-verify`, `arcade-authorize-route`, `arcade-authorize-cancel`, `arcade-journey.integration`. None of their assertions were weakened; the seeding is a precondition the database now insists on, and the `attemptAs` cases in `arcade-verify` that exercise illegal holder/user pairs still do so, because they override the row in memory rather than in the database. Four new cases in `arcade-attempt-constraints.test.ts` cover the fix, asked of the DATABASE rather than of drizzle's declaration: an actor who is not a person is refused, a `user_id` that is not a person is refused (with the actor seeded, so only the second key can be what refuses), deleting the person takes their attempt, and every foreign key has an index leading with its column. All four were watched failing against the base schema — "accepted" where a constraint name was expected, the attempt surviving its person, and `server_id`/`user_id` both unindexed. `assertion-shape.test.ts` loses its entry for `arcade-attempt-store.integration.test.ts:81`: the `const actors: string[] = []` it flagged as written-and-never-read is now read by that file's `removePeople` cleanup, so the offence is fixed rather than moved. Co-Authored-By: Claude Opus 5 (1M context) --- ...0059_pending_authorization_person_keys.sql | 23 + server/drizzle/meta/0059_snapshot.json | 10017 ++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 29 +- .../tests/arcade-attempt-constraints.test.ts | 108 +- server/tests/arcade-attempt-schema.test.ts | 16 +- .../arcade-attempt-store.integration.test.ts | 49 +- server/tests/arcade-authorize-cancel.test.ts | 21 +- server/tests/arcade-authorize-route.test.ts | 53 +- ...completion-transaction.integration.test.ts | 29 +- .../tests/arcade-journey.integration.test.ts | 5 + server/tests/arcade-verify.test.ts | 9 + server/tests/assertion-shape.test.ts | 14 +- server/tests/support/people.ts | 60 + 14 files changed, 10374 insertions(+), 66 deletions(-) create mode 100644 server/drizzle/0059_pending_authorization_person_keys.sql create mode 100644 server/drizzle/meta/0059_snapshot.json create mode 100644 server/tests/support/people.ts diff --git a/server/drizzle/0059_pending_authorization_person_keys.sql b/server/drizzle/0059_pending_authorization_person_keys.sql new file mode 100644 index 000000000..b13e24595 --- /dev/null +++ b/server/drizzle/0059_pending_authorization_person_keys.sql @@ -0,0 +1,23 @@ +-- `pending_authorizations.actor_id` and `.user_id` named `users.id` from the day 0057 created the +-- table and carried no key, so a deleted person left attempt rows pointing at nobody. Both get the +-- key here, both CASCADE: an attempt is in-flight working state with a fifteen-minute life, holding +-- no grant and no secret, and what a person authorized is recorded in `audit_events`, which is +-- keyless on purpose so the trail outlives them. RESTRICT would instead make somebody undeletable +-- while a browser of theirs sat mid-redirect. +-- +-- THE ORPHANS GO FIRST, because `ADD CONSTRAINT` is not conditional: on any deployment that has run +-- 0057 and since deleted a person, the statement below would stop dead on rows the column was never +-- able to refuse. They are deleted rather than repaired — there is nothing to repair a row to, and +-- nulling `user_id` would trip `pending_authorizations_holder_check` on a `person` attempt. Nothing +-- reads an attempt whose person is gone; the verifier finds attempts from a live session. +DELETE FROM "pending_authorizations" +WHERE NOT EXISTS (SELECT 1 FROM "users" WHERE "users"."id" = "pending_authorizations"."actor_id") + OR ("pending_authorizations"."user_id" IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM "users" WHERE "users"."id" = "pending_authorizations"."user_id"));--> statement-breakpoint +ALTER TABLE "pending_authorizations" ADD CONSTRAINT "pending_authorizations_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pending_authorizations" ADD CONSTRAINT "pending_authorizations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +-- A foreign key is not indexed on the referencing side, so until now deleting one `mcp_servers` row +-- made 0057's cascade scan this table, and so did every lookup by server. `user_id` gets one for +-- the same reason, now that it is a key too. +CREATE INDEX "pending_authorizations_server_idx" ON "pending_authorizations" USING btree ("server_id");--> statement-breakpoint +CREATE INDEX "pending_authorizations_user_idx" ON "pending_authorizations" USING btree ("user_id"); diff --git a/server/drizzle/meta/0059_snapshot.json b/server/drizzle/meta/0059_snapshot.json new file mode 100644 index 000000000..fda05ba43 --- /dev/null +++ b/server/drizzle/meta/0059_snapshot.json @@ -0,0 +1,10017 @@ +{ + "id": "c162a85c-1b12-4d32-b575-61c742357991", + "prevId": "d7748e18-de30-4092-afcd-21350f0829a1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "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": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "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": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_authorizations_user_idx": { + "name": "pending_authorizations_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_user_id_users_id_fk": { + "name": "pending_authorizations_user_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_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": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_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": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 579c04461..1ad4b1691 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -414,6 +414,13 @@ "when": 1791924168752, "tag": "0058_pending_authorization_checks", "breakpoints": true + }, + { + "idx": 59, + "version": "7", + "when": 1791942219457, + "tag": "0059_pending_authorization_person_keys", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index f115d5992..595c3c5d3 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -460,11 +460,32 @@ export const brokeredConnections = pgTable( * `completed_at` is what makes completion happen exactly once: the verifier claims the row by * setting it, and a second return finds it set. `auth_id` is what binds a callback to this attempt; * an attempt holding null can never complete. + * + * BOTH COLUMNS THAT NAME A PERSON CARRY THE FOREIGN KEY, AND BOTH CASCADE. `actor_id` and + * `user_id` are `users.id`, and a column that names a table without a key is a column the database + * will let drift: a deleted person left attempt rows pointing at nobody, and the verifier's + * "find the attempt for this session" would then be matching a key no session can ever present. + * + * CASCADE RATHER THAN RESTRICT, AND THIS ROW IS NOT THE AUDIT TRAIL. An attempt is in-flight + * working state with a fifteen-minute life and at most one row per actor, holding no grant and no + * secret; what a person authorized is recorded in `audit_events`, which deliberately carries + * NO key to `users` for exactly the reason the trail must outlive them. `RESTRICT` here would make + * a person undeletable for fifteen minutes because they happened to have a browser mid-redirect, + * which is a legitimate deletion blocked by ephemeral state. Note the contrast with + * {@link brokeredConnections}, whose `user_id` is keyless ON PURPOSE: that row IS the record of + * which apps somebody held, and `retireConnectionsFor` reads it AFTER the `users` row is gone. + * Nothing reads an attempt after its person is gone. + * + * `server_id`'s key gets an index of its own. A foreign key is not indexed by postgres on the + * referencing side, so deleting one `mcp_servers` row made the cascade scan this table, and so did + * every lookup by server. `user_id` gets one for the same reason, now that it too is a key. */ export const pendingAuthorizations = pgTable( "pending_authorizations", { - actorId: text("actor_id").primaryKey(), + actorId: text("actor_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), serverId: text("server_id") .notNull() .references(() => mcpServers.id, { onDelete: "cascade" }), @@ -475,7 +496,9 @@ export const pendingAuthorizations = pgTable( * at runtime and the row this attempt is reconciled into is held to that pair. */ holder: text("holder").$type<"person" | "deployment">().notNull(), - userId: text("user_id"), + userId: text("user_id").references(() => users.id, { + onDelete: "cascade", + }), vendorUserId: text("vendor_user_id").notNull(), authId: text("auth_id"), createdAt: createdAt(), @@ -500,6 +523,8 @@ export const pendingAuthorizations = pgTable( "pending_authorizations_outcome_check", sql`${table.outcome} IS NULL OR ${table.outcome} IN ('completed', 'failed', 'unreconciled', 'withdrawn')`, ), + index("pending_authorizations_server_idx").on(table.serverId), + index("pending_authorizations_user_idx").on(table.userId), ], ); diff --git a/server/tests/arcade-attempt-constraints.test.ts b/server/tests/arcade-attempt-constraints.test.ts index 69496f921..74a102cbf 100644 --- a/server/tests/arcade-attempt-constraints.test.ts +++ b/server/tests/arcade-attempt-constraints.test.ts @@ -12,6 +12,7 @@ import { createDatabase } from "../src/db/client"; import { mcpServers, pendingAuthorizations } from "../src/db/schema/plugins"; import { checkRoster } from "./support/constraints"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); @@ -28,13 +29,34 @@ const attempt = (overrides: Record) => ({ ...overrides, }); +/** + * Every person this file invented, so `afterAll` can take them away again. + * + * `actor_id` and `user_id` both key to `users` and both cascade, so the row a case is about cannot + * be written until its person exists — and deleting the person afterwards is also what deletes the + * attempt. Seeding happens inside {@link insert} rather than in `beforeAll` because the actor id is + * generated per row, and a case that forgot to seed its own would otherwise be refused by the + * foreign key and read as a CHECK that did not fire. + */ +const seeded = new Set(); + +const seedFor = async (row: Record) => { + const people = [row.actorId, row.userId].filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); + for (const id of people) seeded.add(id); + await seedPeople(database, people); +}; + // Awaited rather than handed back as a builder, so a failure says what the database did with the // row and not what drizzle's query object looks like. const insert = async (overrides: Record) => { + const row = attempt(overrides); + await seedFor(row); await database .insert(pendingAuthorizations) // biome-ignore lint/suspicious/noExplicitAny: the point is what the DATABASE refuses, past the declared unions. - .values(attempt(overrides) as any); + .values(row as any); }; /** @@ -65,6 +87,7 @@ beforeAll(async () => { afterAll(async () => { await database.execute(sql`delete from mcp_servers where id = ${serverId}`); + await removePeople(database, [...seeded]); await database.$client.close(); }); @@ -148,3 +171,86 @@ describe("pending_authorizations holds its declared unions", () => { expect(rows[0]?.n).toBe(7); }); }); + +/** + * The two columns that name `users.id`, and whether the database holds them to it. + * + * `0057` created both `actor_id` and `user_id` naming `users.id` in the docblock and carrying no + * key, which is the shape where the schema is documentation and the data is whatever got written: + * a deleted person left attempts pointing at nobody, and an actor id that was never a person at all + * was accepted without complaint. `0059` adds both keys, cascading, and these are the cases that + * say so — asked of the DATABASE, because a `.references()` in drizzle that no migration carried + * into postgres enforces nothing at all. + */ +describe("pending_authorizations keys the people it names", () => { + test("an actor who is not a person is refused", async () => { + const stranger = `ghost-actor-${randomUUID().slice(0, 8)}`; + const refusal = await refusedBy(() => + database + .insert(pendingAuthorizations) + // biome-ignore lint/suspicious/noExplicitAny: an id past the key is exactly what the FK is for. + .values(attempt({ actorId: stranger, userId: stranger }) as any), + ); + expect(refusal).toBe("pending_authorizations_actor_id_users_id_fk"); + }); + + test("a user_id that is not a person is refused", async () => { + // The actor IS a person here, so only the second column can be what refuses: a case that left + // both unseeded would pass on the actor key alone and say nothing about `user_id`. + const actorId = `keyed-actor-${randomUUID().slice(0, 8)}`; + await seedPeople(database, [actorId]); + seeded.add(actorId); + const refusal = await refusedBy(() => + database.insert(pendingAuthorizations).values( + attempt({ + actorId, + userId: `ghost-owner-${randomUUID().slice(0, 8)}`, + // biome-ignore lint/suspicious/noExplicitAny: an id past the key is exactly what the FK is for. + }) as any, + ), + ); + expect(refusal).toBe("pending_authorizations_user_id_users_id_fk"); + }); + + test("deleting the person takes their in-flight attempt with them", async () => { + const actorId = `departing-${randomUUID().slice(0, 8)}`; + await insert({ actorId, userId: actorId }); + const before = await database.execute( + sql`select count(*)::int as n from pending_authorizations where actor_id = ${actorId}`, + ); + expect(before[0]?.n).toBe(1); + + await database.execute(sql`delete from users where id = ${actorId}`); + seeded.delete(actorId); + + const after = await database.execute( + sql`select count(*)::int as n from pending_authorizations where actor_id = ${actorId}`, + ); + expect(after[0]?.n).toBe(0); + }); + + /** + * The index, not merely the key. + * + * Postgres indexes the REFERENCED side of a foreign key and not the referencing side, so the + * `server_id` key `0057` added left every cascade from `mcp_servers` — and every lookup by server + * — scanning this table. There is nothing about a missing index a functional test can observe, so + * it is asked for by name. + */ + test("every foreign key on the table has an index leading with its column", async () => { + const rows = await database.execute(sql` + SELECT indexdef FROM pg_indexes + WHERE schemaname = 'public' AND tablename = 'pending_authorizations' + `); + const definitions = rows.map((row) => String(row.indexdef)); + const led = (column: string) => + definitions.some((definition) => + definition.includes(`USING btree (${column}`), + ); + expect({ + actor_id: led("actor_id"), + server_id: led("server_id"), + user_id: led("user_id"), + }).toEqual({ actor_id: true, server_id: true, user_id: true }); + }); +}); diff --git a/server/tests/arcade-attempt-schema.test.ts b/server/tests/arcade-attempt-schema.test.ts index d950fa1dd..e59edfe78 100644 --- a/server/tests/arcade-attempt-schema.test.ts +++ b/server/tests/arcade-attempt-schema.test.ts @@ -5,11 +5,19 @@ import { sql } from "drizzle-orm"; import { createDatabase } from "../src/db/client"; import { mcpServers } from "../src/db/schema/plugins"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); const serverId = `arcade-attempt-${suite}`; +/* + * The three actors the cases below claim under, seeded as real people because `actor_id` and + * `user_id` now carry the key to `users` that they always claimed in the docblock. Named up here so + * `afterAll` can delete them, and so the cascade takes the attempts with them. + */ +const actors = [`live-${suite}`, `expired-${suite}`, `done-${suite}`]; + /** The one statement a start-attempt route issues: take the key if free, expired, or completed. */ const claim = (actorId: string, authId: string) => database.execute(sql` @@ -32,6 +40,7 @@ const claim = (actorId: string, authId: string) => `); beforeAll(async () => { + await seedPeople(database, actors); await database.insert(mcpServers).values({ id: serverId, title: "Attempt schema", @@ -42,12 +51,13 @@ beforeAll(async () => { afterAll(async () => { await database.execute(sql`delete from mcp_servers where id = ${serverId}`); + await removePeople(database, actors); await database.$client.close(); }); describe("pending_authorizations", () => { test("a second attempt for one actor is refused while the first is live", async () => { - const actor = `live-${suite}`; + const actor = actors[0]!; expect((await claim(actor, "auth-1")).length).toBe(1); expect((await claim(actor, "auth-2")).length).toBe(0); const rows = await database.execute( @@ -57,7 +67,7 @@ describe("pending_authorizations", () => { }); test("an expired attempt is replaceable in one statement", async () => { - const actor = `expired-${suite}`; + const actor = actors[1]!; expect((await claim(actor, "auth-old")).length).toBe(1); await database.execute( sql`update pending_authorizations set created_at = now() - interval '16 minutes' where actor_id = ${actor}`, @@ -68,7 +78,7 @@ describe("pending_authorizations", () => { }); test("a completed attempt is replaceable", async () => { - const actor = `done-${suite}`; + const actor = actors[2]!; expect((await claim(actor, "auth-old")).length).toBe(1); await database.execute( sql`update pending_authorizations set completed_at = now(), outcome = 'completed' where actor_id = ${actor}`, diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 588ba88ea..340f6dd81 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -9,6 +9,7 @@ import type { ConnectedAppBroker } from "../src/plugins/broker"; import type { AccountRef } from "../src/plugins/shared-accounts"; import { createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); @@ -78,10 +79,19 @@ const age = (actorId: string, interval: string) => sql`update pending_authorizations set created_at = created_at - ${interval}::interval where actor_id = ${actorId}`, ); +/** + * An actor id for this suite, AND THE PERSON BEHIND IT. + * + * `pending_authorizations.actor_id` and `.user_id` both key to `users` and both cascade, so an + * attempt cannot be written for somebody who does not exist. Seeding here rather than in each case + * keeps the cases about the store: a case that invented its own id would be refused by the foreign + * key, and the refusal would read as the store failing to claim. + */ const actors: string[] = []; -const actor = (name: string) => { +const actor = async (name: string) => { const id = `${name}-${suite}`; actors.push(id); + await seedPeople(database, [id]); return id; }; @@ -97,12 +107,13 @@ beforeAll(async () => { afterAll(async () => { // Cascades to the attempts. await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + await removePeople(database, actors); await database.$client.close(); }); describe("claimAuthorizationAttempt", () => { test("succeeds when no attempt exists", async () => { - const a = actor("fresh"); + const a = await actor("fresh"); const got = await claim(a); expect(got.claimed).toBe(true); if (!got.claimed) return; @@ -115,7 +126,7 @@ describe("claimAuthorizationAttempt", () => { }); test("a second claim while the first is live returns zero rows and answers the live row", async () => { - const a = actor("live"); + const a = await actor("live"); const first = await claim(a, "connect"); const second = await claim(a, "reconnect"); expect(first.claimed).toBe(true); @@ -130,7 +141,7 @@ describe("claimAuthorizationAttempt", () => { }); test("succeeds after the first expired, replacing it", async () => { - const a = actor("expired"); + const a = await actor("expired"); await claim(a, "old"); await age(a, "16 minutes"); const got = await claim(a, "new"); @@ -144,7 +155,7 @@ describe("claimAuthorizationAttempt", () => { }); test("succeeds after the first completed, replacing it", async () => { - const a = actor("completed"); + const a = await actor("completed"); await claim(a, "old"); await database.execute( sql`update pending_authorizations set completed_at = now(), outcome = 'completed' where actor_id = ${a}`, @@ -161,7 +172,7 @@ describe("claimAuthorizationAttempt", () => { }); test("two concurrent claims by one actor: exactly one wins", async () => { - const a = actor("race"); + const a = await actor("race"); const results = await Promise.all([claim(a, "one"), claim(a, "two")]); expect(results.filter((r) => r.claimed).length).toBe(1); expect(results.filter((r) => !r.claimed).length).toBe(1); @@ -170,8 +181,8 @@ describe("claimAuthorizationAttempt", () => { describe("recordAuthorizationMint", () => { test("writes the ar_ onto the attempt and leaves another actor's alone", async () => { - const a = actor("mint"); - const other = actor("mint-other"); + const a = await actor("mint"); + const other = await actor("mint-other"); const mine = await claim(a); await claim(other); if (!mine.claimed) throw new Error("claim failed"); @@ -187,7 +198,7 @@ describe("recordAuthorizationMint", () => { }); test("a mint against a different created_at writes nothing", async () => { - const a = actor("mint-stale"); + const a = await actor("mint-stale"); await claim(a); const wrote = await store.recordAuthorizationMint({ actorId: a, @@ -201,7 +212,7 @@ describe("recordAuthorizationMint", () => { describe("discardAuthorizationAttempt", () => { test("deletes the named attempt, an unminted one included", async () => { - const a = actor("discard"); + const a = await actor("discard"); const got = await claim(a); if (!got.claimed) throw new Error("claim failed"); const deleted = await store.discardAuthorizationAttempt({ @@ -213,7 +224,7 @@ describe("discardAuthorizationAttempt", () => { }); test("leaves a NEWER attempt by the same actor standing", async () => { - const a = actor("newer"); + const a = await actor("newer"); const older = await claim(a, "older"); if (!older.claimed) throw new Error("claim failed"); await age(a, "16 minutes"); @@ -249,17 +260,19 @@ describe("discardAuthorizationAttempt", () => { describe("authorizationAttemptFor", () => { test("answers the live attempt", async () => { - const a = actor("for-live"); + const a = await actor("for-live"); await claim(a); expect((await store.authorizationAttemptFor(a))?.actorId).toBe(a); }); test("answers null when there is none", async () => { - expect(await store.authorizationAttemptFor(actor("for-none"))).toBeNull(); + expect( + await store.authorizationAttemptFor(await actor("for-none")), + ).toBeNull(); }); test("answers null past the TTL", async () => { - const a = actor("for-expired"); + const a = await actor("for-expired"); await claim(a); await age(a, "16 minutes"); expect(await store.authorizationAttemptFor(a)).toBeNull(); @@ -296,7 +309,7 @@ describe("naming an attempt by created_at", () => { }; test("a mint matches a row written with the column default", async () => { - const a = actor("default-mint"); + const a = await actor("default-mint"); const row = await insertWithDefaultCreatedAt(a); const wrote = await store.recordAuthorizationMint({ actorId: a, @@ -308,7 +321,7 @@ describe("naming an attempt by created_at", () => { }); test("a discard matches a row written with the column default", async () => { - const a = actor("default-discard"); + const a = await actor("default-discard"); const row = await insertWithDefaultCreatedAt(a); expect( await store.discardAuthorizationAttempt({ @@ -320,7 +333,7 @@ describe("naming an attempt by created_at", () => { }); test("a created_at that has been through JSON still names its attempt", async () => { - const a = actor("json-mint"); + const a = await actor("json-mint"); const got = await claim(a); if (!got.claimed) throw new Error("claim failed"); // The declared type says `Date`; a serialised attempt hands back a string. @@ -337,7 +350,7 @@ describe("naming an attempt by created_at", () => { }); test("a created_at that names no instant is refused, not matched", async () => { - const a = actor("bad-instant"); + const a = await actor("bad-instant"); await claim(a); await expect( store.recordAuthorizationMint({ diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 0a20dbd12..b1d812a72 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -19,6 +19,7 @@ import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginRoutes } from "../src/plugins/routes"; import { createPluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); @@ -119,10 +120,17 @@ function appAs( ); } +/** + * An actor id for this suite, AND THE `users` ROW BEHIND IT. + * + * `pending_authorizations.actor_id` and `.user_id` key to `users` and cascade, so the attempt these + * cases cancel cannot be minted for somebody who does not exist. + */ const actors: string[] = []; -const person = (name: string) => { +const person = async (name: string) => { const id = `${name}-${suite}`; actors.push(id); + await seedPeople(database, [id]); return id; }; @@ -191,6 +199,7 @@ afterAll(async () => { .delete(pendingAuthorizations) .where(inArray(pendingAuthorizations.actorId, actors)); } + await removePeople(database, actors); await database.$client.close(); }); @@ -202,7 +211,7 @@ beforeEach(() => { describe("DELETE /servers/:id/authorize", () => { test("cancelling removes the attempt and a fresh authorization immediately succeeds", async () => { - const actor = person("cancel"); + const actor = await person("cancel"); const app = appAs("user", actor); expect((await authorize(app, personalApp)).status).toBe(200); expect(await attemptsFor(actor)).toHaveLength(1); @@ -230,7 +239,7 @@ describe("DELETE /servers/:id/authorize", () => { }); test("cancelling an attempt whose mint never completed (no auth_id) removes it", async () => { - const actor = person("noid"); + const actor = await person("noid"); nextAuthId = null; const app = appAs("user", actor); expect((await authorize(app, personalApp)).status).toBe(200); @@ -241,14 +250,14 @@ describe("DELETE /servers/:id/authorize", () => { }); test("cancelling when no attempt exists is not an error", async () => { - const actor = person("none"); + const actor = await person("none"); const response = await cancel(appAs("user", actor), personalApp); expect(response.status).toBeLessThan(300); expect(await attemptsFor(actor)).toEqual([]); }); test("a cancel naming one app does not cancel this actor's attempt on another", async () => { - const actor = person("otherapp"); + const actor = await person("otherapp"); const app = appAs("user", actor); expect((await authorize(app, personalApp)).status).toBe(200); const [before] = await attemptsFor(actor); @@ -265,7 +274,7 @@ describe("DELETE /servers/:id/authorize", () => { }); test("a cancel issued for an OLD created_at does not remove a NEWER attempt by the same actor", async () => { - const actor = person("stale"); + const actor = await person("stale"); const plain = appAs("user", actor); expect((await authorize(plain, personalApp)).status).toBe(200); const [old] = await attemptsFor(actor); diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index a97c6809a..1ec318023 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -20,6 +20,7 @@ import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginRoutes } from "../src/plugins/routes"; import { createPluginStore, type PluginStore } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); @@ -119,10 +120,17 @@ function appAs( ); } +/** + * An actor id for this suite, AND THE `users` ROW BEHIND IT. + * + * `pending_authorizations.actor_id` and `.user_id` key to `users` and cascade, so the attempt a + * mint writes cannot name somebody who does not exist. + */ const actors: string[] = []; -const person = (name: string) => { +const person = async (name: string) => { const id = `${name}-${suite}`; actors.push(id); + await seedPeople(database, [id]); return id; }; @@ -190,6 +198,7 @@ afterAll(async () => { .delete(pendingAuthorizations) .where(inArray(pendingAuthorizations.actorId, actors)); } + await removePeople(database, actors); await database.$client.close(); }); @@ -201,7 +210,7 @@ beforeEach(() => { describe("POST /servers/:id/authorize", () => { test("mints for the named action and the row records the exact account and the authorization id", async () => { - const actor = person("mint"); + const actor = await person("mint"); const response = await authorize(appAs("user", actor), personalApp); expect(response.status).toBe(200); expect(await response.json()).toEqual({ authorizationUrl: SECRET_URL }); @@ -232,7 +241,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a null authorization id is recorded as NULL, never collapsed", async () => { - const actor = person("nullid"); + const actor = await person("nullid"); nextAuthId = null; const response = await authorize(appAs("user", actor), personalApp); expect(response.status).toBe(200); @@ -242,7 +251,7 @@ describe("POST /servers/:id/authorize", () => { }); test("an action the app does not publish is a 400 with no attempt and no mint", async () => { - const actor = person("unpublished"); + const actor = await person("unpublished"); const response = await authorize(appAs("user", actor), personalApp, { action: "Gmail.DeleteEverything", }); @@ -252,7 +261,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a differently-cased action is not accepted", async () => { - const actor = person("cased"); + const actor = await person("cased"); const response = await authorize(appAs("user", actor), personalApp, { action: ACTION.toLowerCase(), }); @@ -262,7 +271,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a non-admin on a Shared app is a 403 with no attempt", async () => { - const actor = person("shared-user"); + const actor = await person("shared-user"); const response = await authorize(appAs("user", actor), sharedApp); expect(response.status).toBe(403); expect(mints).toEqual([]); @@ -270,7 +279,7 @@ describe("POST /servers/:id/authorize", () => { }); test("an admin on a Shared app records the deployment's account on the row", async () => { - const actor = person("shared-admin"); + const actor = await person("shared-admin"); const response = await authorize(appAs("admin", actor), sharedApp); expect(response.status).toBe(200); const [row] = await attemptsFor(actor); @@ -283,7 +292,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a second authorization while one is live is a 409 naming the first", async () => { - const actor = person("live"); + const actor = await person("live"); const app = appAs("user", actor); expect((await authorize(app, personalApp)).status).toBe(200); const [first] = await attemptsFor(actor); @@ -300,7 +309,7 @@ describe("POST /servers/:id/authorize", () => { }); test("an authorization after the first expired succeeds, replacing it", async () => { - const actor = person("expired"); + const actor = await person("expired"); const app = appAs("user", actor); expect((await authorize(app, personalApp)).status).toBe(200); await database.execute( @@ -318,7 +327,7 @@ describe("POST /servers/:id/authorize", () => { }); test("the authorization url appears in no audit row", async () => { - const actor = person("audit"); + const actor = await person("audit"); expect((await authorize(appAs("user", actor), personalApp)).status).toBe( 200, ); @@ -331,7 +340,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a deployment whose broker runs no authorization flow answers 503 and writes no attempt", async () => { - const actor = person("composio"); + const actor = await person("composio"); const response = await authorize( appAs("user", actor, "composio"), composioApp, @@ -345,7 +354,7 @@ describe("POST /servers/:id/authorize", () => { }); test("the gate answers before whose account it is: a non-admin on a Shared app of such a deployment is a 503, not a 403", async () => { - const actor = person("composio-shared"); + const actor = await person("composio-shared"); const response = await authorize( appAs("user", actor, "composio"), sharedApp, @@ -365,7 +374,7 @@ describe("POST /servers/:id/authorize", () => { }; test("a definite vendor refusal deletes the attempt, and a late cleanup can never delete a newer one", async () => { - const actor = person("definite"); + const actor = await person("definite"); beforeMint = refuse; const response = await authorize(appAs("user", actor), personalApp); expect(response.status).toBe(502); @@ -410,7 +419,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a definite refusal whose clean-up also fails still answers the refusal, not a bodyless 500", async () => { - const actor = person("definite-stuck"); + const actor = await person("definite-stuck"); beforeMint = refuse; const response = await authorize( appAs("user", actor, "arcade", { @@ -432,7 +441,7 @@ describe("POST /servers/:id/authorize", () => { }); test("an authored refusal is definite: nothing reached the vendor, so the attempt is deleted", async () => { - const actor = person("authored"); + const actor = await person("authored"); const { BrokerRefusalError } = await import("../src/plugins/broker"); beforeMint = async () => { throw new BrokerRefusalError("Arcade needs an action."); @@ -443,7 +452,7 @@ describe("POST /servers/:id/authorize", () => { }); test("an uncertain failure (status undefined) leaves the attempt standing with a null auth_id", async () => { - const actor = person("uncertain"); + const actor = await person("uncertain"); beforeMint = async () => { throw new APIConnectionError({ message: "socket dropped" }); }; @@ -468,7 +477,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a non-SDK throw (the adapter's missing-id Error) is uncertain too: the attempt stands", async () => { - const actor = person("noid"); + const actor = await person("noid"); beforeMint = async () => { throw new Error("Arcade answered an authorization with no id."); }; @@ -495,7 +504,7 @@ describe("POST /servers/:id/authorize", () => { } as Partial; test("the person is told, is not sent to the consent screen, and the url does not travel", async () => { - const actor = person("unrecorded"); + const actor = await person("unrecorded"); const response = await authorize( appAs("user", actor, "arcade", failWrite), personalApp, @@ -515,7 +524,7 @@ describe("POST /servers/:id/authorize", () => { }); test("the actor is not blocked: the dead attempt is cleared and a second start mints again", async () => { - const actor = person("unrecorded-retry"); + const actor = await person("unrecorded-retry"); expect( ( await authorize( @@ -536,7 +545,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a failed clean-up still answers a sentence rather than throwing past the handler", async () => { - const actor = person("unrecorded-stuck"); + const actor = await person("unrecorded-stuck"); const response = await authorize( appAs("user", actor, "arcade", { ...failWrite, @@ -557,7 +566,7 @@ describe("POST /servers/:id/authorize", () => { }); test("two concurrent starts by one actor yield exactly one 200 and one 409", async () => { - const actor = person("race"); + const actor = await person("race"); // Hold the winner's mint open so the loser's claim runs while it is in flight. let release: () => void = () => {}; const held = new Promise((resolve) => { @@ -582,7 +591,7 @@ describe("POST /servers/:id/authorize", () => { }); test("a Shared app is authorized under the SESSION SUBJECT, not the deployment's credential account", async () => { - const actor = person("team-bot"); + const actor = await person("team-bot"); expect((await authorize(appAs("admin", actor), sharedApp)).status).toBe( 200, ); diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index cea1d5d30..7874fb780 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -14,6 +14,7 @@ import type { ConnectedAppBroker } from "../src/plugins/broker"; import type { AccountRef } from "../src/plugins/shared-accounts"; import { createPluginStore } from "../src/plugins/store"; import { testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; /* * FOUR CONNECTIONS RATHER THAN THE SHARED `TEST_POOL` OF TWO, because the race below drives two @@ -119,7 +120,20 @@ const account = (actor: string): AccountRef => ({ vendorUserId: `v-${actor}`, }); -const actor = (name: string) => `${name}-${suite}`; +/** + * An actor id for this suite, AND THE PERSON BEHIND IT. + * + * `pending_authorizations.actor_id` and `.user_id` key to `users` and cascade, so an attempt cannot + * exist for somebody who does not. Seeded here so each case stays about the completion transaction + * rather than about the foreign key. + */ +const people: string[] = []; +const actor = async (name: string) => { + const id = `${name}-${suite}`; + people.push(id); + await seedPeople(database, [id]); + return id; +}; const toolkit = (name: string) => `tk-${name}-${suite}`; /** A claimed attempt carrying a minted `ar_...`, which is what a callback arrives against. */ @@ -187,12 +201,13 @@ afterAll(async () => { await database .delete(brokeredConnections) .where(like(brokeredConnections.app, `%-${suite}`)); + await removePeople(database, people); await database.$client.close(); }); describe("completeAuthorizationAttempt", () => { test("one call writes exactly one connection and one audit event naming the action and scopes", async () => { - const a = actor("single"); + const a = await actor("single"); const app = toolkit("single"); await attemptWith(a, "ar_single"); @@ -231,7 +246,7 @@ describe("completeAuthorizationAttempt", () => { }); test("two CONCURRENT calls write exactly one connection and one audit event, and the loser answers as the winner would", async () => { - const a = actor("race"); + const a = await actor("race"); const app = toolkit("race"); await attemptWith(a, "ar_race"); @@ -262,7 +277,7 @@ describe("completeAuthorizationAttempt", () => { }); test("a failure between the connection write and the audit write persists NEITHER", async () => { - const a = actor("rollback"); + const a = await actor("rollback"); const app = toolkit("rollback"); await attemptWith(a, "ar_rollback"); @@ -289,7 +304,7 @@ describe("completeAuthorizationAttempt", () => { }); test("an auth_id that does not match the row writes nothing and leaves the row UNCHANGED", async () => { - const a = actor("mismatch"); + const a = await actor("mismatch"); const app = toolkit("mismatch"); await attemptWith(a, "ar_real"); @@ -316,7 +331,7 @@ describe("completeAuthorizationAttempt", () => { describe("failAuthorizationAttempt / markAuthorizationUnreconciled", () => { test("mark an attempt failed, matched on (actor_id, auth_id)", async () => { - const a = actor("failed"); + const a = await actor("failed"); await attemptWith(a, "ar_failed"); expect( @@ -338,7 +353,7 @@ describe("failAuthorizationAttempt / markAuthorizationUnreconciled", () => { }); test("mark an attempt unreconciled, matched on (actor_id, auth_id)", async () => { - const a = actor("unreconciled"); + const a = await actor("unreconciled"); await attemptWith(a, "ar_unreconciled"); expect( diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index a4a4825c3..4a0d4d972 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -78,6 +78,7 @@ import { createSharedUseStore } from "../src/plugins/shared-use-store"; import { createPluginStore } from "../src/plugins/store"; import fixture from "./fixtures/arcade-catalogue.json"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; import { testEnvironment } from "./support/environment"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); @@ -586,6 +587,9 @@ async function consent(serverRowId: string, actionName: string) { } beforeAll(async () => { + // The administrator walking this journey is a real `users` row, because the attempt rows minted + // along the way key to one on both `actor_id` and `user_id` and cascade from it. + await seedPeople(database, [ADMIN]); installVendor(); const client = createArcadeClient("never-dialled"); broker = client.broker; @@ -611,6 +615,7 @@ afterAll(async () => { .delete(mcpServers) .where(inArray(mcpServers.id, [MAIL_ROW, DIARY_ROW])); await database.delete(agents).where(eq(agents.id, BOT)); + await removePeople(database, [ADMIN]); await database.$client.close(); }); diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index 9e0ba5d17..8b94c4930 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -40,6 +40,7 @@ import { AUTHORIZATION_REFUSALS } from "../src/plugins/routes"; import type { AccountRef } from "../src/plugins/shared-accounts"; import { createPluginStore } from "../src/plugins/store"; import { testDatabaseUrl } from "./support/database"; +import { removePeople, seedPeople } from "./support/people"; import { testEnvironment } from "./support/environment"; /* @@ -400,6 +401,13 @@ async function giveAttempt(options: { } beforeEach(async () => { + /* + * The people the attempts below belong to. `pending_authorizations.actor_id` and `.user_id` key + * to `users` and cascade, so `giveAttempt` cannot write a row for somebody who does not exist. + * Seeded per case rather than once, because a case that deletes the person to watch the cascade + * would otherwise leave the rest of the file without one. + */ + await seedPeople(database, [ACTOR, STRANGER]); confirmCalls = []; statusCalls = []; revokeCalls = []; @@ -436,6 +444,7 @@ afterAll(async () => { .delete(brokeredConnections) .where(like(brokeredConnections.app, `Gmail-${suite}-%`)); await database.delete(mcpServers).where(eq(mcpServers.id, APP)); + await removePeople(database, [ACTOR, STRANGER]); await database.$client.close(); }); diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 92de055c1..990178110 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -889,13 +889,13 @@ const ALLOW: Allowed[] = [ finding: "NEW (round 2 did not find this)", why: "const siblingReads: string[] = [];", }, - { - file: "server/tests/arcade-attempt-store.integration.test.ts", - line: 81, - shape: "e", - finding: "NEW (round 2 did not find this)", - why: "const actors: string[] = [];", - }, + /* + * `server/tests/arcade-attempt-store.integration.test.ts:81` — `const actors: string[] = []`, + * written and never read — is GONE rather than renumbered. The list it collects is now read by + * the `removePeople` in that file's `afterAll`: `pending_authorizations.actor_id` keys to `users` + * and cascades, so the ids the suite invents are people it has to create and then take away, and + * the collection that was decorative is now what the cleanup runs on. + */ { file: "server/tests/arcade-disconnect-cascade.test.ts", line: 36, diff --git a/server/tests/support/people.ts b/server/tests/support/people.ts new file mode 100644 index 000000000..57cb83107 --- /dev/null +++ b/server/tests/support/people.ts @@ -0,0 +1,60 @@ +// server/tests/support/people.ts +// +// `users` rows for the ids a test is about. +// +// Several tables name `users.id` and carry the foreign key that says so, and `pending_authorizations` +// is now one of them — both `actor_id` and `user_id` cascade from it. A test that invented an actor +// id and inserted an attempt under it was relying on a column that named a table and did not hold +// anybody to it; once the key exists, the row the test is actually about cannot be written until the +// person it names exists. That is the constraint doing its job, not the test being awkward, so the +// seeding lives in one place rather than being spelled eight different ways across the arcade files. +import { sql } from "drizzle-orm"; +// `../../src`, not `../src`: this file is one level deeper than the test files it serves. The +// sibling `constraints.ts` has the shallow spelling, which resolves to nothing and silently leaves +// its `Database` as `any` — it is in `typecheck-baseline.txt` as debt, and not a spelling to copy. +import type { createDatabase } from "../../src/db/client"; + +type Database = ReturnType; + +/** + * Make sure a `users` row exists for each id, inventing the rest of the person. + * + * Idempotent on the id, because a suite that seeds the same actor in `beforeAll` and again inside a + * case should not fail on the second call — and because `email` is unique, so the email is derived + * from the id rather than from a counter that two files would collide on. + * + * Does NOT clean up after itself: the caller owns the lifetime, and the cascade this exists for + * means deleting the person is also how a caller deletes everything hanging off them. + */ +export const seedPeople = async ( + database: Database, + ids: readonly string[], +): Promise => { + const unique = [...new Set(ids)].filter((id) => id.length > 0); + if (unique.length === 0) return; + for (const id of unique) { + await database.execute(sql` + INSERT INTO users (id, email, name, email_verified) + VALUES (${id}, ${`${id}@example.invalid`}, ${id}, true) + ON CONFLICT (id) DO NOTHING + `); + } +}; + +/** One person, for the common case where a test names exactly one. */ +export const seedPerson = async ( + database: Database, + id: string, +): Promise => seedPeople(database, [id]); + +/** Remove the people a test seeded, taking everything keyed to them with it. */ +export const removePeople = async ( + database: Database, + ids: readonly string[], +): Promise => { + const unique = [...new Set(ids)].filter((id) => id.length > 0); + if (unique.length === 0) return; + for (const id of unique) { + await database.execute(sql`DELETE FROM users WHERE id = ${id}`); + } +}; From b534b353358d7782c123cecd721125567a0e9f84 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 03:56:56 +0200 Subject: [PATCH 180/232] Settle account_mode on every brokered app that has an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `addBrokeredApp` settles `mcp_servers.account_mode` on its INSERT and nowhere else. The CONFLICT arm of that upsert — the branch that CONVERTS a row which was not brokered into one that is, rewriting url, provenance and vendor — never wrote the column, and the guard that followed could not fill it either: it asked `schemeKind(recorded) === "none"`, which is "was this a no-auth app a moment ago", and `recorded` is `brokeredAppScheme`, which answers `null` for a row that was not brokered. A null is `unreadable`, not `none`. What that leaves is not cosmetic. `accountFor` refuses a call against a brokered row with a null mode as `ambiguous` — "does not say whether it is reached through each person's account or a shared one, so it was not called" — so the app is enabled, listed, connectable, and declines every call. And the obvious remedy does not reach it: the whole block was additionally gated on `configured !== "standing"`, so it runs only on the press that CREATES the auth config and never afterwards. BOTH HALVES ARE FIXED, AND THEY ARE DIFFERENT HALVES. 1. The writer. The fill is now its own statement, after the scheme write and outside its gate, on the ANSWERING row — the row every reader of an app's mode resolves (`brokeredAppRowsAt`), which is not always the row the upsert keyed on, so a fill in the conflict arm would settle a row nothing reads. It asks exactly what the refusal asks: does the row have an account to hold (`schemeKind` other than `none`, re-read from the row because the branch above may or may not have written it), and does it fail to say whose. `IS NULL` lives in the WHERE, so an administrator's Shared choice cannot be lost to it or to a switch racing it. `personal` is the fill: the insert's own default and the conservative one. 2. `0059_settle_brokered_account_mode`, a data-only migration, for the rows that are already stuck. THIS IS NOT BELT AND BRACES. An app that is already enabled cannot be added again without being removed first — the Plugins page's Add is a switch, and switching it off deletes the app and every connection on it — so the writer fix can never reach a row already in this state, and the page offers no "Make personal" either: it reads any non-`shared` value as Personal and shows only "Make shared". Without the migration every deployment that is already broken stays broken. Same predicate as the writer, including the deliberate inclusion of a null `auth_scheme` (`unreadable`, which `accountFor` treats as having an account) and the exclusion of `NO_AUTH`. Call sites of the changed behaviour: `addBrokeredApp` is the only writer touched, and nothing about its signature or its return changes. Its callers are `plugins/routes.ts` (the enable endpoint) and the tests `plugin-store.integration`, `brokered-app-provider.integration`, `mcp-grant-removal.integration`, `plugin-routes.test` (stubbed), `routes-vendor-vocabulary` (stubbed). Readers of the column that now see a value where they saw null: `accountFor` (stops refusing), `accessFor` (`reachedAs` unchanged — a null already answered `person`, and so does `personal`), `callTool`'s `gatedMode` (already defaulted a null to `personal`), the listing and `serverAddress`. No reader changes its answer for a row that already had a mode. Red-green: with the base `store.ts` restored, the two new tests covering the conversion and the re-enable fail with `Expected: "personal" / Received: null`; with the fix they pass. The two guard tests were each watched failing on a planted violation (dropping the `IS NULL` from the WHERE turns Shared into personal; removing the `schemeKind` gate gives a `NO_AUTH` app a mode), and the migration's test was watched failing on a planted violation in each of its three clauses. Co-Authored-By: Claude Opus 5 (1M context) --- .../0059_settle_brokered_account_mode.sql | 30 + server/drizzle/meta/0059_snapshot.json | 9960 +++++++++++++++++ server/drizzle/meta/_journal.json | 9 +- server/src/plugins/store.ts | 88 +- ...-account-mode-backfill.integration.test.ts | 123 + .../brokered-app-provider.integration.test.ts | 104 + 6 files changed, 10290 insertions(+), 24 deletions(-) create mode 100644 server/drizzle/0059_settle_brokered_account_mode.sql create mode 100644 server/drizzle/meta/0059_snapshot.json create mode 100644 server/tests/brokered-account-mode-backfill.integration.test.ts diff --git a/server/drizzle/0059_settle_brokered_account_mode.sql b/server/drizzle/0059_settle_brokered_account_mode.sql new file mode 100644 index 000000000..c22d4d725 --- /dev/null +++ b/server/drizzle/0059_settle_brokered_account_mode.sql @@ -0,0 +1,30 @@ +-- Every brokered app that HAS an account says whose, on the deployments where one already does not. +-- +-- `addBrokeredApp` settles `account_mode` on its INSERT and, from this change on, after its upsert +-- as well. Its CONFLICT arm never did, so an enable that landed on a row that was not brokered — +-- one added by hand, restored, or left by a build that spelled ids differently — converted it to a +-- brokered app and left this column null. `accountFor` refuses a call against such a row as +-- ambiguous ("does not say whether it is reached through each person's account or a shared one"), +-- so the app is enabled, listed, connectable and dead. +-- +-- THIS RUNS BECAUSE THE CODE FIX CANNOT REACH THOSE ROWS. An app that is already enabled cannot be +-- added again without being removed first — the Plugins page's Add is a switch, and switching it +-- off deletes the app and every connection on it — so "press Add again" is not a remedy an +-- administrator can perform, and the Plugins page offers no way to say Personal: it reads any +-- non-`shared` value as Personal already and shows only "Make shared". A fix in the writer alone +-- would leave every deployment that is already in this state in it forever. +-- +-- `personal` is what the insert writes for a new app and the conservative answer: each person +-- reaches their own account. `shared` would hand everybody one account nobody chose to share. +-- +-- ONLY ROWS WITH AN ACCOUNT TO HOLD, and `NO_AUTH` is the one scheme with none — `schemeKind`'s +-- `none`, the same set `mcp_servers.account_mode` is documented as null for. A null `auth_scheme` +-- is NOT in that set: it is `unreadable`, which `accountFor` treats as having an account, so such a +-- row is refused exactly as a recorded `OAUTH2` one is and needs the mode just as much. +-- +-- ONLY ROWS THAT SAY NOTHING. A deployment that chose Shared keeps it. +UPDATE "mcp_servers" + SET "account_mode" = 'personal' + WHERE "provenance" IN ('composio', 'arcade') + AND "account_mode" IS NULL + AND ("auth_scheme" IS NULL OR "auth_scheme" <> 'NO_AUTH'); diff --git a/server/drizzle/meta/0059_snapshot.json b/server/drizzle/meta/0059_snapshot.json new file mode 100644 index 000000000..21191419a --- /dev/null +++ b/server/drizzle/meta/0059_snapshot.json @@ -0,0 +1,9960 @@ +{ + "id": "ce3d7fc0-9ba2-45c7-9b7a-8ec04b00446e", + "prevId": "d7748e18-de30-4092-afcd-21350f0829a1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "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": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "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": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_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": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_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": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 579c04461..e38d3964b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -414,6 +414,13 @@ "when": 1791924168752, "tag": "0058_pending_authorization_checks", "breakpoints": true + }, + { + "idx": 59, + "version": "7", + "when": 1791924768752, + "tag": "0059_settle_brokered_account_mode", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index e165d753a..49a74325e 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3829,34 +3829,76 @@ export function createPluginStore(options: PluginStoreOptions) { .limit(1); if (configured !== "standing" && connections.length === 0) { - /* - * MODE PICKED FOR THE PERSON ONLY WHERE THIS REWRITE IS WHAT GIVES THE APP AN ACCOUNT AT - * ALL, AND NEVER WHERE ONE WAS ALREADY CHOSEN. A row enabled `no-auth` has `accountMode` - * null — there was nothing to hold a mode about, see the insert above — and if this same - * re-add is what turns it into a key or consent app, it needs the ordinary default an - * insert would have given it, or every later read that asks "personal or shared" finds - * null and has no answer. An administrator who already chose Shared keeps that choice. - */ - const [current] = await database - .select({ accountMode: mcpServers.accountMode }) - .from(mcpServers) - .where(eq(mcpServers.id, answering)) - .limit(1); - const bumpAccountMode = - schemeKind(recorded) === "none" && - schemeKind(scheme) !== "none" && - (current?.accountMode ?? null) === null; - await database .update(mcpServers) - .set({ - authScheme: scheme, - updatedAt: new Date(), - ...(bumpAccountMode ? { accountMode: "personal" as const } : {}), - }) + .set({ authScheme: scheme, updatedAt: new Date() }) .where(eq(mcpServers.id, answering)); } + /* + * AN APP WITH AN ACCOUNT LEAVES THIS METHOD SAYING WHOSE, ALWAYS — AND THAT IS A SEPARATE + * STATEMENT FROM THE SCHEME ABOVE FOR A REASON. + * + * CRITERION. After `addBrokeredApp` returns, the row that answers for the app either records + * a mode or is an app with no account to hold one about ({@link schemeKind} `none`). There is + * no third state, and in particular no enabled key-or-consent app with a null here. + * + * REASON. The null is not a cosmetic gap: {@link accountFor} refuses a call against such a + * row as `ambiguous` — "does not say whether it is reached through each person's account or a + * shared one, so it was not called" — so an app in that state is enabled, listed, connectable + * and dead. THREE THINGS HAD TO BE TRUE AT ONCE FOR IT TO HAPPEN AND ALL THREE WERE. The + * INSERT above settles the column, but this method's second branch is the upsert's CONFLICT + * arm, which writes the url and the provenance — it CONVERTS a row that was not brokered + * into one that is — and never wrote this column at all. The guard that stood here then asked + * `schemeKind(recorded) === "none"`, which is the question "was this a no-auth app a moment + * ago"; `recorded` is {@link brokeredAppScheme}, and a row that was not brokered a moment ago + * answers `null`, which is `unreadable` rather than `none`. And the whole block was gated on + * `configured !== "standing"`, so the one obvious remedy — press Add again — reaches it only + * on the single press that created the config and never afterwards. An administrator + * converting a hand-added or restored row got an app that refused every call, and nothing + * they could press repaired it: the Plugins page offers "Make shared" for a null mode (it + * reads any non-`shared` value as Personal) and no way to say Personal at all. + * + * SO THE QUESTION ASKED HERE IS THE ONE THE REFUSAL ASKS, and nothing else: does the row + * answering for this app have an account to hold, and does it fail to say whose. The scheme + * is re-read rather than taken from `scheme` because the branch above may or may not have + * written it, and what matters is what now STANDS on the answering row — including the + * `unreadable` null a converted row carries, which {@link accountFor} treats as having an + * account and which is therefore exactly as dead without a mode as a recorded `OAUTH2`. + * + * `personal` IS THE FILL, which is the insert's own default and the conservative one: it + * reaches each asking person's own account. `shared` would hand everybody one account nobody + * chose to share. + * + * AND NEVER OVER A MODE THAT IS THERE. The `IS NULL` lives in the WHERE rather than in a + * read-then-write, so an administrator's Shared choice cannot be lost to this statement, nor + * to a switch racing it. + * + * ON THE ANSWERING ROW, WHICH IS WHY THE CONFLICT ARM STILL DOES NOT WRITE THE COLUMN. Every + * reader of a brokered app's mode — {@link brokeredAppRowsAt} and so `serverAddress`, the + * listing, the call gate — resolves it off the row that answers for the url, which is not + * always the row the upsert keyed on. A fill in the conflict arm would settle the minted row + * and leave the one that is actually read still null. + * + * ROWS ALREADY STUCK IN THE NULL STATE ARE NOT REACHED FROM HERE AT ALL, because an enabled + * app cannot be re-added without first being removed. They are repaired by + * `0059_settle_brokered_account_mode`, which is the same decision written once against the + * table; see `tests/brokered-account-mode-backfill.integration.test.ts`. + */ + const [settled] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.id, answering)) + .limit(1); + if (schemeKind(settled?.authScheme ?? null) !== "none") { + await database + .update(mcpServers) + .set({ accountMode: "personal", updatedAt: new Date() }) + .where( + and(eq(mcpServers.id, answering), isNull(mcpServers.accountMode)), + ); + } + await recordAuditEvent(auditStore, { eventType: "configuration.changed", targetType: "mcp_server", diff --git a/server/tests/brokered-account-mode-backfill.integration.test.ts b/server/tests/brokered-account-mode-backfill.integration.test.ts new file mode 100644 index 000000000..20bb0e23a --- /dev/null +++ b/server/tests/brokered-account-mode-backfill.integration.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { inArray, sql } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { mcpServers } from "../src/db/schema"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * The migration that gives an existing deployment's dead apps an answer, run against a real + * database. + * + * WHAT IT REPAIRS. `addBrokeredApp` settles `account_mode` on the INSERT and — from the change this + * migration ships beside — after the upsert as well. Its CONFLICT arm never did, so an enable that + * landed on a row which was not brokered converted it and left this column null. `accountFor` + * refuses a call against such a row as ambiguous, so the app is enabled, listed, connectable, and + * declines every call it is asked for. + * + * WHY A MIGRATION AND NOT THE WRITER ALONE. An app that is already enabled cannot be added again + * without being removed first — the Plugins page's Add is a switch, and switching it off takes the + * app and every connection on it — so the repair in `addBrokeredApp` can never reach a row that is + * already in this state. Without this file every deployment that is already broken stays broken. + * + * Found through `_journal.json` rather than by filename, for `last-sign-in-backfill`'s reason: the + * number in front of a migration is not stable across a rebase, and a migration left out of the + * journal never runs at all. So the journal entry is what is asserted and the SQL is read from + * whatever file it names. + */ +const JOURNAL = (await Bun.file( + new URL("../drizzle/meta/_journal.json", import.meta.url), +).json()) as { entries: { idx: number; tag: string }[] }; + +const entry = JOURNAL.entries.find((candidate) => + candidate.tag.endsWith("_settle_brokered_account_mode"), +); + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); + +const PREFIX = "acctmode-backfill-"; + +/** Thrown to unwind the transaction, so the backfill never touches the rows of the database it ran against. */ +class Rollback extends Error {} + +describe("the account-mode backfill migration", () => { + test("is registered in the journal, so it runs", () => { + expect(entry).toBeDefined(); + }); + + test("settles every brokered app with an account and nothing else", async () => { + const migration = await Bun.file( + new URL(`../drizzle/${entry?.tag}.sql`, import.meta.url), + ).text(); + + /** + * One row per case the predicate decides, named for what it is. + * + * `unreadable` is the one worth saying out loud: a null `auth_scheme` is NOT a no-auth app — + * `schemeKind` calls it `unreadable` and `accountFor` treats it as having an account — so it + * is refused exactly as `consent` is and needs the mode just as much. `custom` and + * `first-party` are the two provenances that have no account to hold at all. + */ + const rows = [ + { id: `${PREFIX}consent`, provenance: "composio", scheme: "OAUTH2" }, + { id: `${PREFIX}key`, provenance: "composio", scheme: "API_KEY" }, + { id: `${PREFIX}arcade`, provenance: "arcade", scheme: "OAUTH2" }, + { id: `${PREFIX}unreadable`, provenance: "composio", scheme: null }, + { id: `${PREFIX}noauth`, provenance: "composio", scheme: "NO_AUTH" }, + { id: `${PREFIX}custom`, provenance: "custom", scheme: null }, + { id: `${PREFIX}firstparty`, provenance: "first-party", scheme: null }, + ] as const; + const chosen = `${PREFIX}chosen`; + const ids = [...rows.map((candidate) => candidate.id), chosen]; + const observed = new Map(); + + try { + await database.transaction(async (tx) => { + for (const candidate of rows) { + await tx.insert(mcpServers).values({ + id: candidate.id, + title: candidate.id, + vendor: "Composio", + url: `composio://${candidate.id}`, + provenance: candidate.provenance, + authScheme: candidate.scheme, + }); + } + // An administrator who already chose Shared, which nothing here may overrule. + await tx.insert(mcpServers).values({ + id: chosen, + title: chosen, + vendor: "Composio", + url: `composio://${chosen}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: "shared", + }); + + for (const statement of migration.split("--> statement-breakpoint")) { + if (statement.trim()) await tx.execute(sql.raw(statement)); + } + + const after = await tx + .select({ id: mcpServers.id, accountMode: mcpServers.accountMode }) + .from(mcpServers) + .where(inArray(mcpServers.id, ids)); + for (const row of after) observed.set(row.id, row.accountMode); + + throw new Rollback(); + }); + } catch (error) { + if (!(error instanceof Rollback)) throw error; + } + + expect(Object.fromEntries(observed)).toEqual({ + [`${PREFIX}consent`]: "personal", + [`${PREFIX}key`]: "personal", + [`${PREFIX}arcade`]: "personal", + [`${PREFIX}unreadable`]: "personal", + [`${PREFIX}noauth`]: null, + [`${PREFIX}custom`]: null, + [`${PREFIX}firstparty`]: null, + [chosen]: "shared", + }); + }); +}); diff --git a/server/tests/brokered-app-provider.integration.test.ts b/server/tests/brokered-app-provider.integration.test.ts index feb51b733..b8f32bf0f 100644 --- a/server/tests/brokered-app-provider.integration.test.ts +++ b/server/tests/brokered-app-provider.integration.test.ts @@ -207,3 +207,107 @@ for (const provider of ["composio", "arcade"] as const) { }); } } + +/** + * WHOSE ACCOUNT AN ENABLED APP IS REACHED THROUGH, WHICH IS A QUESTION A ROW IS NEVER ALLOWED TO + * LEAVE THIS METHOD WITHOUT AN ANSWER TO. + * + * `accountFor` refuses a call against a brokered row whose `account_mode` is null as `ambiguous` — + * the app is enabled, listed and connectable, and every call it is asked for is declined. So the + * state these three tests are about is not a tidiness one: it is an app that cannot be used. + * + * The two that would fail without the fix are the two the writer used to miss. `addBrokeredApp` + * settles the column on its INSERT only; its CONFLICT arm converts a row that was NOT brokered + * into one that is, and the guard that followed asked `schemeKind(recorded) === "none"` — "was + * this a no-auth app a moment ago" — which a row that was not brokered at all answers `null` to, + * and a null is `unreadable`, not `none`. The whole block was additionally gated on the auth + * config having just been CREATED, so a second press of Add could never repair what the first + * left. + */ +test("converting a row that was not brokered settles whose account the app is", async () => { + await database.insert(mcpServers).values({ + id: "composio-bapconv", + title: "Typed", + vendor: "typed.example", + url: "https://typed.example/mcp", + provenance: "custom", + addedBy: "admin@example.com", + }); + + await storeFor("composio", "created").addBrokeredApp({ + slug: "bapconv", + title: "Converted", + by: "admin@example.com", + connection: consent, + }); + + const stored = await row("composio-bapconv"); + // The conversion itself, so a failure below is read as the mode and not as the upsert. + expect(stored?.provenance).toBe("composio"); + expect(stored?.url).toBe("composio://bapconv"); + expect(stored?.accountMode).toBe("personal"); +}); + +test("re-enabling an app that never said whose account it is settles it", async () => { + // The state a deployment is already in, written down: brokered, scheme recorded, mode null. + await database.insert(mcpServers).values({ + id: "composio-bapstuck", + title: "Stuck", + vendor: "Composio", + url: "composio://bapstuck", + provenance: "composio", + authScheme: "OAUTH2", + addedBy: "admin@example.com", + }); + + // `standing`: the auth config is already there, which is what every press of Add after the first + // one meets, and what used to skip the whole block. + await storeFor("composio", "standing").addBrokeredApp({ + slug: "bapstuck", + title: "Stuck", + by: "admin@example.com", + connection: consent, + }); + + expect((await row("composio-bapstuck"))?.accountMode).toBe("personal"); +}); + +test("re-enabling leaves an administrator's Shared choice alone", async () => { + await database.insert(mcpServers).values({ + id: "composio-bapshared", + title: "Shared", + vendor: "Composio", + url: "composio://bapshared", + provenance: "composio", + authScheme: "OAUTH2", + accountMode: "shared", + sharedVendorUserId: "openbot-deployment:test:abc", + addedBy: "admin@example.com", + }); + + await storeFor("composio", "standing").addBrokeredApp({ + slug: "bapshared", + title: "Shared", + by: "admin@example.com", + connection: consent, + }); + + const stored = await row("composio-bapshared"); + expect(stored?.accountMode).toBe("shared"); + expect(stored?.sharedVendorUserId).toBe("openbot-deployment:test:abc"); +}); + +test("an app with no account to hold is left saying so", async () => { + await storeFor("composio", "created").addBrokeredApp({ + slug: "bapnoauth", + title: "Hacker News", + by: "admin@example.com", + connection: { kind: "no-auth" }, + }); + + const stored = await row("composio-bapnoauth"); + expect(stored?.authScheme).toBe("NO_AUTH"); + // `schemeKind` `none`: there is no account, so there is no mode, and the column says that rather + // than claiming one. + expect(stored?.accountMode).toBeNull(); +}); From aa8775834762ffc1586c374a2ef7be2542099755 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:11:10 +0200 Subject: [PATCH 181/232] fix(tests): await the person/actor helpers B7 made async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B7 added FOREIGN KEYs on `pending_authorizations.actor_id` and `.user_id`, so its tests had to create real `users` rows and the `person()` / `actor()` helpers became async. B7 awaited every call site that existed when it ran. B5, B2 and B3 added new call sites in the same two files against the synchronous version. Each branch type-checked green alone; the merge combined an async helper with sync callers and `typecheck:tests` reported 26 TS2345/TS2322 errors — a `Promise` reaching a `string` parameter at every one of them. No behaviour change: the tests were already passing because bun resolves the promise before the assertion reads it; the ids were objects, not strings, and the comparisons that mattered were made elsewhere. Also drops the one baselined error that no longer occurs (arcade-malformed-listing-engine.test.ts:84, fixed by A3 in wave 0 and reported stale by four separate agents). Baseline: 276. This is the composition defect class the convergence audit named CC-D, caught before merge by the CL-4 lever rather than by the next review round. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/arcade-authorize-cancel.test.ts | 6 +++--- .../arcade-completion-transaction.integration.test.ts | 8 ++++---- server/tests/typecheck-baseline.txt | 1 - 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index b149877b4..294f04421 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -337,7 +337,7 @@ describe("DELETE /servers/:id/authorize", () => { * ordinary. */ test("an attempt on an app whose url no longer parses as brokered can still be cancelled", async () => { - const actor = person("moved"); + const actor = await person("moved"); const app = appAs("user", actor); expect((await authorize(app, movedApp)).status).toBe(200); expect(await attemptsFor(actor)).toHaveLength(1); @@ -361,7 +361,7 @@ describe("DELETE /servers/:id/authorize", () => { }); test("an administrator demoted mid-consent can still cancel their own Shared-app attempt", async () => { - const actor = person("demoted"); + const actor = await person("demoted"); expect((await authorize(appAs("admin", actor), sharedApp)).status).toBe( 200, ); @@ -382,7 +382,7 @@ describe("DELETE /servers/:id/authorize", () => { * `{ cancelled: false }` that claims the request was understood. */ test("with no attempt of this actor's, a non-brokered app is still refused in so many words", async () => { - const actor = person("refused"); + const actor = await person("refused"); const app = appAs("user", actor); await database .update(mcpServers) diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index c319b0075..da0ad42bb 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -258,8 +258,8 @@ describe("completeAuthorizationAttempt", () => { * admin read joins `users` on for an email. */ test("a DEPLOYMENT-held completion records the administrator who consented, and a later one supersedes", async () => { - const first = actor("shared-first"); - const second = actor("shared-second"); + const first = await actor("shared-first"); + const second = await actor("shared-second"); const app = toolkit("shared"); const deployment: AccountRef = { holder: "deployment", @@ -455,7 +455,7 @@ describe("failAuthorizationAttempt / markAuthorizationUnreconciled", () => { * not a lock ordering. Concurrency is what `completeAuthorizationAttempt`'s own race test proves. */ test("a late failure callback does NOT reopen an attempt that already completed", async () => { - const a = actor("late-fail"); + const a = await actor("late-fail"); const app = toolkit("late-fail"); await attemptWith(a, "ar_late_fail"); @@ -493,7 +493,7 @@ describe("failAuthorizationAttempt / markAuthorizationUnreconciled", () => { /* The same rule among the three non-completion verdicts: the first one to settle the row wins. */ test("a second verdict does not overwrite the first one an attempt settled on", async () => { - const a = actor("settled"); + const a = await actor("settled"); await attemptWith(a, "ar_settled"); expect( diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index c683c23f8..be4079837 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -52,7 +52,6 @@ server/tests/arcade-confirm-engine.test.ts:41: error TS2322: Type 'number | unde server/tests/arcade-connection-kind-engine.test.ts:85: error TS2322: Type 'number | undefined' is not assignable to type 'number'. server/tests/arcade-connection-kind-engine.test.ts:87: error TS2322: Type 'Engine | null' is not assignable to type 'Engine'. server/tests/arcade-empty-vendor-structures-engine.test.ts:51: error TS2322: Type 'number | undefined' is not assignable to type 'number'. -server/tests/arcade-malformed-listing-engine.test.ts:84: error TS2322: Type 'number | undefined' is not assignable to type 'number'. server/tests/arcade-pagination-engine.test.ts:218: error TS2322: Type 'number | undefined' is not assignable to type 'number'. server/tests/arcade-vendor-error-engine.test.ts:127: error TS2345: Argument of type '{ account: { holder: "person"; userId: string; vendorUserId: string; }; action: string; }' is not assignable to parameter of type '{ account: AccountRef; toolkit: string; returnUrl: string; action?: string | undefined; }'. server/tests/arcade-vendor-error-engine.test.ts:73: error TS2322: Type 'number | undefined' is not assignable to type 'number'. From e7d12e1f1f131c5db558700039097b2d0c90129f Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:19:18 +0200 Subject: [PATCH 182/232] fix(db): read the migrations journal from the table the migrator writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reach check in apply-migrations.ts read `drizzle.__drizzle_migrations` by literal while `migrate()` writes wherever its migrationsSchema / migrationsTable say. Had the two ever diverged, the check would find no table, treat the database as empty, and let the migrator walk past a renumbered migration — the refusal switched off with no signal. Both the reader (to_regclass probe + select) and the writer (migrate()) now take one MigrationsJournal, defaulting to MIGRATIONS_JOURNAL, the single spelling of the table in server code. The integration test's own count query reads the same constant. applyMigrations gains an optional third parameter `journal`. Call sites: - server/scripts/migrate.ts:49 — unchanged, uses the default - server/tests/migration-reach.integration.test.ts — existing cases use the default; the new case passes a non-default journal New test watched failing on two plants: reader hard-coded back to the literal, and writer ignoring the journal — both "Received value: undefined" where MigrationsOutOfReachError was expected. Co-Authored-By: Claude Opus 5.5 --- server/src/db/apply-migrations.ts | 35 ++++++++-- .../tests/migration-reach.integration.test.ts | 67 ++++++++++++++++++- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/server/src/db/apply-migrations.ts b/server/src/db/apply-migrations.ts index 64daa44e0..cad98695f 100644 --- a/server/src/db/apply-migrations.ts +++ b/server/src/db/apply-migrations.ts @@ -19,21 +19,43 @@ import { readMigrationFolder, } from "./migration-reach"; +/** + * Where the migrator keeps its bookkeeping, named once. + * + * `migrate()` writes to whatever `migrationsSchema` / `migrationsTable` it is handed, and the + * reach check below reads the same table to decide whether migrating is safe. If the two ever named + * different tables the check would read an absent table, see nothing applied, and wave every + * migration through — the refusal silently switched off. So both are handed this one object, and + * nothing else in the server spells the name. + */ +export type MigrationsJournal = { + migrationsSchema: string; + migrationsTable: string; +}; + +export const MIGRATIONS_JOURNAL: MigrationsJournal = { + migrationsSchema: "drizzle", + migrationsTable: "__drizzle_migrations", +}; + /** * What the migrator has already recorded here, or nothing at all. * * `to_regclass` rather than a query that may fail: a database the migrator has never touched has no - * `drizzle` schema, and selecting from a table that is not there would be an error indistinguishable + * journal schema, and selecting from a table that is not there would be an error indistinguishable * from a database that cannot be read. */ -async function appliedMigrations(client: Sql): Promise { +async function appliedMigrations( + client: Sql, + { migrationsSchema, migrationsTable }: MigrationsJournal, +): Promise { const [present] = await client<{ table: string | null }[]>` - select to_regclass('drizzle.__drizzle_migrations')::text as table + select to_regclass(format('%I.%I', ${migrationsSchema}::text, ${migrationsTable}::text))::text as table `; if (!present?.table) return []; const rows = await client<{ hash: string; created_at: string | null }[]>` - select hash, created_at from drizzle.__drizzle_migrations + select hash, created_at from ${client(migrationsSchema)}.${client(migrationsTable)} `; return rows.map((row) => ({ hash: row.hash, @@ -52,9 +74,10 @@ export class MigrationsOutOfReachError extends Error { export async function applyMigrations( client: Sql, migrationsFolder: string, + journal: MigrationsJournal = MIGRATIONS_JOURNAL, ): Promise { const folder = await readMigrationFolder(migrationsFolder); - const applied = await appliedMigrations(client); + const applied = await appliedMigrations(client, journal); const outOfReach = migrationsOutOfReach(folder, applied); if (outOfReach.length > 0) { @@ -63,5 +86,5 @@ export async function applyMigrations( ); } - await migrate(drizzle(client), { migrationsFolder }); + await migrate(drizzle(client), { migrationsFolder, ...journal }); } diff --git a/server/tests/migration-reach.integration.test.ts b/server/tests/migration-reach.integration.test.ts index 90cbb4688..20dc1f726 100644 --- a/server/tests/migration-reach.integration.test.ts +++ b/server/tests/migration-reach.integration.test.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import postgres from "postgres"; import { applyMigrations, + MIGRATIONS_JOURNAL, + type MigrationsJournal, MigrationsOutOfReachError, } from "../src/db/apply-migrations"; import { testDatabaseUrl } from "./support/database"; @@ -14,7 +16,7 @@ import { testDatabaseUrl } from "./support/database"; * database and nothing short of one can show it. * * A database is created per case and dropped afterwards. A schema inside the shared test database - * would not do: the migrator's own bookkeeping lives at `drizzle.__drizzle_migrations`, and these + * would not do: the migrator's own bookkeeping lives in one table (`MIGRATIONS_JOURNAL`), and these * cases are entirely about what that one table already contains, so they need a database whose * contents nothing else wrote. * @@ -141,7 +143,8 @@ describe("a database that ran a branch before the branch was merged", () => { // the branch left it, which is the state an operator can reason about. expect(await exists(client, "from_main")).toBe(false); const [row] = await client<{ count: string }[]>` - select count(*)::text as count from drizzle.__drizzle_migrations + select count(*)::text as count + from ${client(MIGRATIONS_JOURNAL.migrationsSchema)}.${client(MIGRATIONS_JOURNAL.migrationsTable)} `; expect(row?.count).toBe("2"); } finally { @@ -180,6 +183,66 @@ describe("a database that ran a branch before the branch was merged", () => { }); }); +describe("a journal kept somewhere other than the default", () => { + /* + * The check and the migrator must read and write the same bookkeeping table. Were the check to + * look somewhere fixed while the migrator honoured its configuration, the check would find no + * table, conclude nothing had been applied, and let the migrator walk past the unreachable + * migration exactly as if there were no check at all. + */ + const elsewhere: MigrationsJournal = { + migrationsSchema: "c4_journal", + migrationsTable: "applied_migrations", + }; + + test("is the table the check reads, so a renumber is still refused", async () => { + const url = await throwawayDatabase(); + const client = postgres(url, { max: 1, onnotice: () => {} }); + try { + await applyMigrations( + client, + await folderOf([first, asShipped]), + elsewhere, + ); + + const merged = await folderOf([ + first, + { + tag: "0001_from_main", + when: 2_000_000, + sql: 'CREATE TABLE "from_main" ("id" text PRIMARY KEY);\n', + }, + { + ...asShipped, + tag: "0002_branch_renumbered", + when: 4_000_000, + sql: 'ALTER TABLE "branch" ADD COLUMN "renumbered" boolean;\n', + }, + ]); + + const refusal = await applyMigrations(client, merged, elsewhere).then( + () => undefined, + (error: unknown) => error, + ); + expect(refusal).toBeInstanceOf(MigrationsOutOfReachError); + expect(await exists(client, "from_main")).toBe(false); + + // And the bookkeeping is where it was configured to be, and nowhere else. + const [row] = await client<{ count: string }[]>` + select count(*)::text as count + from ${client(elsewhere.migrationsSchema)}.${client(elsewhere.migrationsTable)} + `; + expect(row?.count).toBe("2"); + const [fallback] = await client<{ present: string | null }[]>` + select to_regclass(format('%I.%I', ${MIGRATIONS_JOURNAL.migrationsSchema}::text, ${MIGRATIONS_JOURNAL.migrationsTable}::text))::text as present + `; + expect(fallback?.present).toBeNull(); + } finally { + await client.end({ timeout: 5 }); + } + }); +}); + describe("an ordinary database", () => { test("migrates from empty and then forward, without being refused", async () => { const url = await throwawayDatabase(); From c5695686828f8b60db80edd45d5536901c287da3 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:19:42 +0200 Subject: [PATCH 183/232] fix(migrate): keep the out-of-reach refusal when releasing the connection fails `scripts/migrate.ts` released its connection with an awaited `client.end()` in a `finally`. A rejection there replaces the error in flight, so a failed release turned the MigrationsOutOfReachError refusal -- the one message this script exists to deliver -- into a bare socket error. The run is now an exported `runMigrations(client, folder, apply?)`, executed only under `import.meta.main`. On failure the release's own rejection is logged as `migrations-connection-release-failed` and the run's error is rethrown; after a clean run a failed release still fails the run. Call sites of the changed module: `bun scripts/migrate.ts` from docker-compose.yml, docker/s6/scripts/migrate.sh, charts/openbot/templates/migrations/job.yaml, server/package.json `db:migrate`, .github/workflows/ci.yml, docs/deployment.md -- all run it as an executable, unchanged behaviour. `runMigrations` has one new caller: server/tests/migrate-teardown.test.ts. Co-Authored-By: Claude Opus 5.5 --- server/scripts/migrate.ts | 82 +++++++++++++++++++-------- server/tests/migrate-teardown.test.ts | 58 +++++++++++++++++++ 2 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 server/tests/migrate-teardown.test.ts diff --git a/server/scripts/migrate.ts b/server/scripts/migrate.ts index 939466296..0420f2597 100644 --- a/server/scripts/migrate.ts +++ b/server/scripts/migrate.ts @@ -18,37 +18,69 @@ * `src/db/migration-reach`. */ import { join } from "node:path"; -import postgres from "postgres"; +import postgres, { type Sql } from "postgres"; import { applyMigrations } from "../src/db/apply-migrations"; -const databaseUrl = process.env.DATABASE_URL; -if (!databaseUrl) { - throw new Error( - "DATABASE_URL must be configured before running a database migration command", - ); -} - -/* - * One connection, and `max: 1`. +/** + * The whole run against one connection, and the connection released afterwards. * - * Migrations are a single ordered conversation with the database, and a pool would let two - * statements that must be ordered land on different connections. + * Exported, and the work injectable, so that what happens when the release itself fails can be + * tested without a database that refuses to close. `tests/migrate-teardown.test.ts`. */ -const client = postgres(databaseUrl, { max: 1, onnotice: () => {} }); +export async function runMigrations( + client: Sql, + migrationsFolder: string, + apply: typeof applyMigrations = applyMigrations, +): Promise { + try { + /* + * Guarded, because the migrator's own idea of "applied" is one number. + * + * It runs a migration when the journal stamps it later than the newest stamp the database holds, + * and it has nothing to say about a migration stamped earlier that it has never run. See + * `src/db/migration-reach`: a database that ran a branch's migrations before that branch was + * merged and renumbered holds a stamp newer than the migrations the merge brought in, and the + * unguarded migrator walks past them and prints the line below anyway. + */ + await apply(client, migrationsFolder); + console.info(JSON.stringify({ type: "migrations-applied", status: "ok" })); + } catch (failure) { + /* + * Released whatever happened, so a failure exits rather than hanging on an open socket — but + * NOT with an awaited `end()` in a `finally`. A rejection there replaces the error in flight, + * and the error in flight is the one this script exists to deliver: the refusal naming the + * migrations a database walked past would leave as a bare socket error. The release's own + * failure is reported beside it, and the run's failure is the one that propagates. + */ + await client.end({ timeout: 5 }).catch((teardown: unknown) => { + console.error( + JSON.stringify({ + type: "migrations-connection-release-failed", + error: + teardown instanceof Error ? teardown.message : String(teardown), + }), + ); + }); + throw failure; + } + // After a clean run a failed release has nothing to hide, so it is the run's outcome. + await client.end({ timeout: 5 }); +} + +if (import.meta.main) { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error( + "DATABASE_URL must be configured before running a database migration command", + ); + } -try { /* - * Guarded, because the migrator's own idea of "applied" is one number. + * One connection, and `max: 1`. * - * It runs a migration when the journal stamps it later than the newest stamp the database holds, - * and it has nothing to say about a migration stamped earlier that it has never run. See - * `src/db/migration-reach`: a database that ran a branch's migrations before that branch was - * merged and renumbered holds a stamp newer than the migrations the merge brought in, and the - * unguarded migrator walks past them and prints the line below anyway. + * Migrations are a single ordered conversation with the database, and a pool would let two + * statements that must be ordered land on different connections. */ - await applyMigrations(client, join(import.meta.dir, "..", "drizzle")); - console.info(JSON.stringify({ type: "migrations-applied", status: "ok" })); -} finally { - // Released whatever happened, so a failure exits rather than hanging on an open socket. - await client.end({ timeout: 5 }); + const client = postgres(databaseUrl, { max: 1, onnotice: () => {} }); + await runMigrations(client, join(import.meta.dir, "..", "drizzle")); } diff --git a/server/tests/migrate-teardown.test.ts b/server/tests/migrate-teardown.test.ts new file mode 100644 index 000000000..d6a8bd9ca --- /dev/null +++ b/server/tests/migrate-teardown.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { Sql } from "postgres"; +import { runMigrations } from "../scripts/migrate"; +import { MigrationsOutOfReachError } from "../src/db/apply-migrations"; + +/** + * The migration runner releases its connection in a `finally`, and an awaited rejection there + * REPLACES whatever was already in flight. The error the run exists to deliver — the refusal that + * names the migrations a database walked past — must survive a release that fails. + * + * No database: a connection whose `end` rejects is the whole subject, and a real one does not + * reject on demand. + */ + +const REFUSAL = + "refusing to migrate: 0057_example is stamped earlier than a migration this database has applied"; + +function connectionWhoseEndRejects(failure: Error) { + const end = mock(async () => { + throw failure; + }); + return { client: { end } as unknown as Sql, end }; +} + +async function settle(run: Promise): Promise { + return run.then( + () => "resolved", + (error: unknown) => error, + ); +} + +describe("runMigrations teardown", () => { + test("a rejecting release does not replace the out-of-reach refusal", async () => { + const { client, end } = connectionWhoseEndRejects( + new Error("write CONNECTION_ENDED"), + ); + const refuse = async () => { + throw new MigrationsOutOfReachError(REFUSAL); + }; + + const outcome = await settle(runMigrations(client, "unused", refuse)); + + expect(outcome).toBeInstanceOf(MigrationsOutOfReachError); + expect((outcome as Error).message).toBe(REFUSAL); + expect(end).toHaveBeenCalledTimes(1); + }); + + test("a rejecting release after a clean run is still a failure", async () => { + const teardown = new Error("write CONNECTION_ENDED"); + const { client, end } = connectionWhoseEndRejects(teardown); + const migrate = async () => {}; + + const outcome = await settle(runMigrations(client, "unused", migrate)); + + expect(outcome).toBe(teardown); + expect(end).toHaveBeenCalledTimes(1); + }); +}); From 163822a74c7c15a362c6fe4a0c1c7d1dbed506de Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:20:36 +0200 Subject: [PATCH 184/232] fix(plugins): clear the vendor deadline when the call throws synchronously withVendorDeadline armed its timer and then built the race as Promise.race([call(signal), deadline]). A callback that threw before returning a promise threw out of the helper before the race or its .finally existed: the timer stayed armed, aborted nothing ten seconds later, and rejected `deadline` with no subscriber (an unhandled rejection after the person had already been answered). A caller's .catch(...) on the returned promise was bypassed entirely. The call is now invoked inside a Promise executor, so a synchronous throw becomes the returned promise's rejection; the race subscribes, .finally clears the timer, and .catch handlers run. Reachable: the Composio connect route builds brokerReturnUrl(...) as the callback's argument, and it throws synchronously on a scheme-less OPENBOT_APP_URL. Call sites of withVendorDeadline (server/src/plugins/routes.ts), all now covered by the helper-level fix; none needed a local change: - settledStatus poll: flow.status (STATUS_CALL_TIMEOUT_MS) - GET directory: composio.broker.listApps() - POST add-by-slug: composio.broker.listApps() - connection fields: composio.broker.connectionFields(...) - Composio connect: composio.broker.authorize({ returnUrl: brokerReturnUrl(...) }) <- the reachable synchronous throw - grants: broker.grantsFor(...) - Arcade authorize mint: composio.broker.authorize(...) (returnUrl built outside, in its own try) - verify: flow.confirmReturn(...) (STATUS_CALL_TIMEOUT_MS) - context-changed: store.siblingApps(...), composio.broker.revoke(...) - completion: composio.broker.authProviderOf(...).catch(() => null) <- a sync throw here skipped the .catch and failed a live consent as not-recorded Other Promise.race-with-timer constructions in server/src (self-host-banner, learning/auto-assign x2, learning/routes, routines/run-turn, voice/routes) either evaluate the call before arming the timer or sit inside try/finally; none has this hole. Tests: - server/tests/vendor-deadline-sync-throw.test.ts (new): scheme-less appUrl on the Composio connect route leaves no 10s deadline armed and no unhandled rejection. - server/tests/arcade-verify.test.ts: a synchronously-throwing authProviderOf still completes with authProvider "". The stub's authProviderOf is no longer `async` so the case can throw synchronously. Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/routes.ts | 13 +- server/tests/arcade-verify.test.ts | 30 ++- .../tests/vendor-deadline-sync-throw.test.ts | 183 ++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 server/tests/vendor-deadline-sync-throw.test.ts diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index e47fd2cf9..34c5013d1 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -306,12 +306,23 @@ function withVendorDeadline( reject(new VendorTimeoutError(afterMs)); }, afterMs); }); + /* + * A CALL THAT THROWS BEFORE IT RETURNS A PROMISE IS A REJECTION, NOT AN ESCAPE. The deadline is + * already armed by this line, so invoking `call` bare inside the race would let a synchronous + * throw — `brokerReturnUrl` refusing a scheme-less `OPENBOT_APP_URL` while the callback builds its + * argument is the one that happens — leave this function before the race and its `.finally` + * exist: the timer stays armed, aborts nothing anybody is waiting on and rejects `deadline` with + * no one subscribed, and a caller's `.catch(...)` on the returned promise is skipped entirely. + * Calling it inside an executor turns that throw into this promise's rejection, still + * synchronously and still exactly once. + */ + const answer = new Promise((resolve) => resolve(call(controller.signal))); /* * `Promise.race` subscribes to both, so a call that answers AFTER the deadline has its rejection * observed here and never surfaces as an unhandled one. Its answer is dropped unread, which is * also the D31-safe thing to do with it. */ - return Promise.race([call(controller.signal), deadline]).finally(() => { + return Promise.race([answer, deadline]).finally(() => { if (timer !== undefined) clearTimeout(timer); }); } diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index fe6a87bb6..a202ab0e2 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -115,7 +115,12 @@ const broker: ConnectedAppBroker = { * on ANOTHER provider, held by the same identity and not ended by this revoke, is the control * for the sibling cases below. */ - authProviderOf: async (toolkit) => authProviderAnswer(toolkit), + /* + * NOT `async`, so a case below can make this throw SYNCHRONOUSLY — the shape a broker method + * that validates its argument before it returns a promise has. Every other case hands back a + * promise exactly as before. + */ + authProviderOf: (toolkit) => authProviderAnswer(toolkit), authorizations: { status: async (id) => { statusCalls.push(id); @@ -1350,6 +1355,29 @@ describe("a return leg that completes", () => { expect(events[0]?.payload).toMatchObject({ authProvider: "" }); }); + /** + * AND ONE THAT THROWS BEFORE IT EVEN ANSWERS IS THE SAME ANSWER. The route reads the provider as + * `withVendorDeadline(...).catch(() => null)`, and a synchronous throw used to leave the helper + * before its promise existed — so the `.catch` was never reached, the throw took the whole + * completion down with it, and the deadline's timer was left armed behind it. An audit label is + * no more worth refusing a live consent over when the vendor fails fast than when it fails slow. + */ + test("an auth provider lookup that throws synchronously is recorded as unnamed, and still completes", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + authProviderAnswer = () => { + throw new Error("refused before it asked"); + }; + + const response = await verify(ACTOR, "?flow_id=flow-abc"); + expect(response.headers.get("location")).toBe(LANDING); + + const { connections, events } = await written(); + expect(connections.length).toBe(1); + expect(events.length).toBe(1); + expect(events[0]?.payload).toMatchObject({ authProvider: "" }); + expect((await attemptRow())?.outcome).toBe("completed"); + }); + /** * TWO CALLBACKS AT ONCE, with `Promise.all` and never a sequential replay: a replay proves only * that the row was already consumed, while the race is what the conditional UPDATE exists for. diff --git a/server/tests/vendor-deadline-sync-throw.test.ts b/server/tests/vendor-deadline-sync-throw.test.ts new file mode 100644 index 000000000..5350c4395 --- /dev/null +++ b/server/tests/vendor-deadline-sync-throw.test.ts @@ -0,0 +1,183 @@ +// server/tests/vendor-deadline-sync-throw.test.ts +/** + * A vendor call that throws BEFORE it returns a promise still has its deadline cleared. + * + * `withVendorDeadline` arms a ten-second timer and then races it against the call. Built inline as + * `Promise.race([call(signal), deadline])`, a call that throws SYNCHRONOUSLY threw out of the + * helper before the race existed: no `.finally` was ever attached, so the timer stayed armed, and + * ten seconds after the person had been answered it aborted nothing anybody was waiting on and + * rejected a promise nobody held — an unhandled rejection in a deployment that had already replied. + * + * REACHABLE, NOT HYPOTHETICAL. The Composio connect route builds its return address INSIDE the + * deadline's callback, and `brokerReturnUrl` throws synchronously on an `OPENBOT_APP_URL` with its + * scheme left off — `app.example` rather than `https://app.example`. The route still answers its + * refusal (the throw lands in the handler's `try`), which is why nothing on the response shows it; + * what shows it is the timer, so the timer is what this asserts on. + * + * HOW THE TIMER IS WATCHED. Only timers armed for the deadline's own bound are captured, and they + * are captured WITHOUT being scheduled, so this file does not wait ten seconds to see one fire. Any + * capture the route did not clear is then fired by hand, which is exactly what the event loop would + * have done, and the rejection it produces is caught by an `unhandledRejection` listener rather + * than taking the test runner with it. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Hono, type MiddlewareHandler } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import type { AccountModeSwitch } from "../src/plugins/account-mode"; +import type { ComposioBroker } from "../src/plugins/broker"; +import { createPluginRoutes } from "../src/plugins/routes"; +import type { SharedUseStore } from "../src/plugins/shared-use-store"; +import type { PluginStore } from "../src/plugins/store"; + +/** `VENDOR_CALL_TIMEOUT_MS` in `routes.ts`. Not exported, and a timer of any other length is not this one. */ +const VENDOR_CALL_TIMEOUT_MS = 10_000; +const SERVER_ID = "brokered-gmail"; + +type Captured = { fire: () => void; cleared: boolean }; + +const realSetTimeout = globalThis.setTimeout; +const realClearTimeout = globalThis.clearTimeout; +let captured: Captured[] = []; +let unhandled: unknown[] = []; +const onUnhandled = (reason: unknown) => { + unhandled.push(reason); +}; + +beforeEach(() => { + captured = []; + unhandled = []; + process.on("unhandledRejection", onUnhandled); + globalThis.setTimeout = (( + handler: (...args: unknown[]) => void, + ms?: number, + ...args: unknown[] + ) => { + if (ms !== VENDOR_CALL_TIMEOUT_MS) + return realSetTimeout(handler, ms, ...args); + const entry: Captured = { fire: () => handler(...args), cleared: false }; + captured.push(entry); + return entry as unknown as ReturnType; + }) as typeof setTimeout; + globalThis.clearTimeout = ((handle: unknown) => { + const entry = captured.find((candidate) => candidate === handle); + if (entry) entry.cleared = true; + else realClearTimeout(handle as ReturnType); + }) as typeof clearTimeout; +}); + +afterEach(() => { + globalThis.setTimeout = realSetTimeout; + globalThis.clearTimeout = realClearTimeout; + process.off("unhandledRejection", onUnhandled); +}); + +/** A Composio deployment whose app URL was configured without its scheme. */ +function deployment() { + const authorized: string[] = []; + const store = { + brokerProvider: "composio", + serverAddress: async (serverId: string) => + serverId === SERVER_ID + ? { + id: SERVER_ID, + title: "Gmail", + url: "composio://gmail", + authScheme: "OAUTH2", + } + : undefined, + serverUrls: async () => ["composio://gmail"], + accountRefFor: async (_serverId: string, actorId: string) => ({ + ref: { + holder: "person" as const, + userId: actorId, + vendorUserId: actorId, + }, + title: "Gmail", + mode: "personal" as const, + }), + brokeredConnection: async () => null, + listSkills: async () => [], + } as unknown as PluginStore; + + const broker = { + grantIsLocal: true, + authProviderOf: async () => null, + authorizations: null, + listApps: async () => [], + authorize: async (input: { toolkit: string }) => { + authorized.push(input.toolkit); + return { + redirectUrl: "https://vendor.example/consent", + authorizationId: null, + }; + }, + } as unknown as ComposioBroker; + + const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: "person-1", + email: "person@example.test", + role: "user", + } as never); + await next(); + }; + + const app = new Hono().route( + "/api/plugins", + createPluginRoutes( + store, + signedIn, + async () => true, + { + publicUrl: "https://openbot.example", + // THE MISTAKE: no scheme, so `brokerReturnUrl` throws as the callback's argument is built. + appUrl: "app.example", + encryptionKey: `${"A".repeat(43)}=`, + personHasAccess: async () => true, + }, + { broker, provider: "composio" }, + { + modes: {} as unknown as AccountModeSwitch, + use: {} as unknown as SharedUseStore, + }, + ), + ); + return { + authorized, + connect: () => + app.request( + `http://openbot.test/api/plugins/servers/${SERVER_ID}/connect`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }, + ), + }; +} + +test("a return address that throws while the call is built leaves no deadline armed", async () => { + const it = deployment(); + const response = await it.connect(); + + // The refusal itself is unchanged: answered, not a 500, and the vendor never asked. + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).not.toBe(500); + expect(it.authorized).toEqual([]); + + // The deadline WAS armed — otherwise the next assertion is about nothing. + expect(captured.length).toBe(1); + const leftArmed = captured.filter((entry) => !entry.cleared).length; + + // Fire whatever was not cleared, as the event loop would have ten seconds later. + for (const entry of captured) if (!entry.cleared) entry.fire(); + await new Promise((resolve) => realSetTimeout(resolve, 0)); + + // It was cleared, so nothing is left to fire after the person has been answered... + expect(leftArmed).toBe(0); + // ...and nothing rejects later with nobody holding the promise. + expect(unhandled).toEqual([]); +}); From 6d349effe45c60bd2a6f39812c116c46f148baf1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:20:49 +0200 Subject: [PATCH 185/232] fix(arcade): read a missing authorization block one way, as needing none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grantStateOf read an absent `requirements.authorization` as `unknown` ("could not be read") while requiresAuthorization read the same absence as "needs nothing" and filed the app `no-auth`. Every action of a no-auth toolkit (Apollo, once APOLLO_API_KEY is set; any action with `requirements: null`) showed the broker-outage state on /servers/:id/grants forever. The vendor's listing settles it: Apollo publishes no authorization block (fixtures/arcade-catalogue.json), so absence means none. Both sites now read one helper, authorizationRequirementOf, which answers none | unreadable(field, shape) | required(authorization). The catalogue still refuses an unreadable shape; the grants screen still answers `unknown` for one. A no-auth action whose secrets are all set is now `authorized` with no scopes; an unset secret is still `needs-operator`. Call sites whose behaviour changed: - grantStateOf: only caller grantsFor (arcade-adapter.ts), exposed as broker.grantsFor -> GET /servers/:id/grants (routes.ts). Absent authorization now `authorized` []; a non-object `requirements` now gets an "unreadable" reason instead of "named no authorization". - requiresAuthorization: only caller listApps; behaviour unchanged (same answers, same refusal sentences), now through the helper. - authorizationRequirementOf (new): called by the two above only. Other readers of the field's absence, audited and consistent with "none": scopesOf and authProvidersNamedBy (arcade-adapter.ts), scopesOf (arcade.ts) — absence yields no scopes / no provider. Test: arcade-adapter-status.test.ts asks both questions of the same silent rows (red on base: grants answered `unknown`). The four-states row Github.CountStargazers `{ met: false }` pinned the old reading; it now carries an unreadable authorization so `unknown` is still covered. arcade-pagination-engine.test.ts pinned neither reading (every row carries authorization; it counts rows only). Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/arcade-adapter.ts | 82 ++++++++++++++++++---- server/tests/arcade-adapter-status.test.ts | 59 +++++++++++++++- 2 files changed, 128 insertions(+), 13 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 03f7c15ee..1811f7605 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -457,6 +457,59 @@ function shapeOf(value: unknown): string { return `a ${typeof value}`; } +/** + * What one listed action's `requirements.authorization` SAYS, read once for every caller that asks. + * + * THE ONE READING OF THIS FIELD'S ABSENCE. Two readers once reached opposite answers on the same + * row: {@link requiresAuthorization} read a missing block as "needs nothing" and filed the app as + * `no-auth`, while {@link grantStateOf} read the same silence as "could not be read" and drew every + * action of that app as a broker outage on the grants screen, forever. The vendor's own listing + * settles which is right — `Apollo` publishes NO authorization block and a deployment secret + * instead, verbatim in `fixtures/arcade-catalogue.json` — so `null` and an absent field are + * `none`, here and therefore everywhere. + * + * A PRESENT FIELD THIS DEPLOYMENT CANNOT READ IS NEITHER ANSWER. It is `unreadable`, with WHICH + * field and the shape that arrived there, and each caller decides what an unreadable row costs it: + * the catalogue refuses, the grants screen says `unknown`. + */ +type AuthorizationRequirement = + | { kind: "none" } + | { + kind: "unreadable"; + field: "requirements" | "authorization"; + shape: string; + } + | { kind: "required"; authorization: Record }; + +function authorizationRequirementOf(item: unknown): AuthorizationRequirement { + const requirements = record(item)?.requirements; + if (requirements === undefined || requirements === null) { + return { kind: "none" }; + } + if (typeof requirements !== "object" || Array.isArray(requirements)) { + return { + kind: "unreadable", + field: "requirements", + shape: shapeOf(requirements), + }; + } + const authorization = (requirements as Record).authorization; + if (authorization === undefined || authorization === null) { + return { kind: "none" }; + } + if (typeof authorization !== "object" || Array.isArray(authorization)) { + return { + kind: "unreadable", + field: "authorization", + shape: shapeOf(authorization), + }; + } + return { + kind: "required", + authorization: authorization as Record, + }; +} + /** * Whether ARCADE SAYS this action needs a person to authorize it — read, never assumed. * @@ -493,21 +546,18 @@ function shapeOf(value: unknown): string { * `metadata: null` shows Arcade does say. */ function requiresAuthorization(item: unknown, toolkit: string): boolean { - const requirements = record(item)?.requirements; - if (requirements === undefined || requirements === null) return false; - if (typeof requirements !== "object" || Array.isArray(requirements)) { + const read = authorizationRequirementOf(item); + if (read.kind === "unreadable" && read.field === "requirements") { throw new BrokerRefusalError( - `Arcade sent ${shapeOf(requirements)} where ${toolkit}'s requirements belong, and that field is what says whether somebody has to authorize this app at all. A shape this deployment cannot read is not the vendor saying "nothing to authorize", so it is not read as one — the catalogue stops here rather than offer ${toolkit} under a flow nobody established it has. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`, + `Arcade sent ${read.shape} where ${toolkit}'s requirements belong, and that field is what says whether somebody has to authorize this app at all. A shape this deployment cannot read is not the vendor saying "nothing to authorize", so it is not read as one — the catalogue stops here rather than offer ${toolkit} under a flow nobody established it has. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`, ); } - const authorization = (requirements as Record).authorization; - if (authorization === undefined || authorization === null) return false; - if (typeof authorization !== "object" || Array.isArray(authorization)) { + if (read.kind === "unreadable") { throw new BrokerRefusalError( - `Arcade sent ${shapeOf(authorization)} where ${toolkit}'s authorization requirement belongs, and this deployment reads that object to decide whether connecting ${toolkit} means sending somebody to a consent screen. A shape this deployment cannot read decides neither way, so the catalogue stops here rather than guess. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`, + `Arcade sent ${read.shape} where ${toolkit}'s authorization requirement belongs, and this deployment reads that object to decide whether connecting ${toolkit} means sending somebody to a consent screen. A shape this deployment cannot read decides neither way, so the catalogue stops here rather than guess. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`, ); } - return true; + return read.kind === "required"; } /** @@ -1007,17 +1057,25 @@ function grantStateOf(item: unknown): GrantState[] { ]; } - const authorization = record(requirements?.authorization); - if (!authorization) { + // NO AUTHORIZATION BLOCK IS THE VENDOR SAYING THIS ACTION NEEDS NONE, with every secret it does + // need already read as set above — so it is usable, and `authorized` with no scopes is the state + // that says so. See {@link authorizationRequirementOf} for why this is the one reading. + const read = authorizationRequirementOf(item); + if (read.kind === "none") + return [{ action, state: "authorized", scopes: [] }]; + if (read.kind === "unreadable") { return [ { action, state: "unknown", reason: - "Arcade's listing named no authorization for this action, so what this account holds could not be read.", + read.field === "requirements" + ? `Arcade listed this action's requirements as ${read.shape}, so what this account holds could not be read.` + : `Arcade listed this action's authorization as ${read.shape}, so what this account holds could not be read.`, }, ]; } + const { authorization } = read; const oauth2 = record(authorization.oauth2)?.scopes; const scopes = Array.isArray(oauth2) ? oauth2.filter((s): s is string => typeof s === "string") diff --git a/server/tests/arcade-adapter-status.test.ts b/server/tests/arcade-adapter-status.test.ts index 0d7a88d1e..7d0f94359 100644 --- a/server/tests/arcade-adapter-status.test.ts +++ b/server/tests/arcade-adapter-status.test.ts @@ -94,7 +94,9 @@ describe("grantsFor maps a toolkit listing to this deployment's vocabulary", () { key: "GITHUB_SERVER_URL", met: false, status_reason: "unset" }, ], }), - tool("Github.CountStargazers", { met: false }), + // An authorization block in a shape this deployment cannot read. NOT a row with no block at + // all: that is the vendor saying the action needs none, and is `authorized` — see below. + tool("Github.CountStargazers", { met: false, authorization: "oauth2" }), ]); const { broker } = createArcadeClient("never-dialled"); @@ -155,6 +157,61 @@ describe("grantsFor maps a toolkit listing to this deployment's vocabulary", () }); }); +/** + * ONE READING OF A MISSING AUTHORIZATION BLOCK, ASKED OF BOTH QUESTIONS ON THE SAME ROW. + * + * The catalogue files an app whose actions publish no `requirements.authorization` as `no-auth`; + * the grants screen once read that same silence as "could not be read" and drew every action of + * such an app as `unknown` — the broker-outage state — forever, with nothing anybody could do about + * it. `Apollo` is the live shape (one deployment secret, no authorization block), here with its + * secret SET, which is the state that used to stick. + */ +describe("an action that publishes no authorization needs none, on every screen", () => { + const silent = [ + tool("Apollo.EnrichOrganization", { + met: true, + secrets: [{ key: "APOLLO_API_KEY", met: true }], + }), + tool("Apollo.SearchPeople", null), + { + qualified_name: "Apollo.SearchOrganizations", + toolkit: { name: "Github" }, + }, + ]; + + test("the catalogue says no-auth and the grants screen says usable, never unknown", async () => { + listing(silent); + const { broker } = createArcadeClient("never-dialled"); + + const apps = await broker.listApps(); + expect(apps.map((app) => app.connection)).toEqual([{ kind: "no-auth" }]); + + expect(await broker.grantsFor({ toolkit: "Github", account })).toEqual([ + { action: "Apollo.EnrichOrganization", state: "authorized", scopes: [] }, + { action: "Apollo.SearchPeople", state: "authorized", scopes: [] }, + { action: "Apollo.SearchOrganizations", state: "authorized", scopes: [] }, + ]); + }); + + test("an UNSET secret on such an action is still the operator's, not authorized", async () => { + listing([ + tool("Apollo.EnrichOrganization", { + met: false, + secrets: [{ key: "APOLLO_API_KEY", met: false }], + }), + ]); + const { broker } = createArcadeClient("never-dialled"); + + expect(await broker.grantsFor({ toolkit: "Apollo", account })).toEqual([ + { + action: "Apollo.EnrichOrganization", + state: "needs-operator", + missing: ["APOLLO_API_KEY"], + }, + ]); + }); +}); + /** * THE LEAK TEST. `auth.status` answers with a LIVE provider access token and the person's full * profile; the narrowing is a type, not a rule somebody has to remember. From 783e2f61df07a7ec4f171bdc0983061947cac11c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:21:35 +0200 Subject: [PATCH 186/232] fix(db): pick the migrator's high-water mark the way drizzle picks it migration-reach used Math.max over created_at; drizzle's PgDialect.migrate reads `order by created_at desc limit 1` and compares Number(created_at). Postgres sorts NULLs first in a descending order, so one NULL created_at row is the mark drizzle reads (Number(null) = 0) and every applied migration is re-run (observed: `type "agent_type" already exists`), while Math.max picked the newest real stamp and the guard passed. - New migratorHighWaterMark(applied): undefined when empty, 0 when any createdAt is NULL, otherwise the max. Single source for the mark. - AppliedMigration.createdAt is now `number | null`. - migrationsOutOfReach also returns applied migrations stamped ABOVE the mark (would be re-run); empty with honest stamps. - outOfReachRefusal uses the same mark, names NULL rows, separates "would skip" from "would run a second time", and gives the NULL-specific repair. Call sites (signatures widened, not narrowed; no caller edit needed): - migrationsOutOfReach / outOfReachRefusal: server/src/db/apply-migrations.ts applyMigrations; server/tests/migration-reach.test.ts. - AppliedMigration: server/src/db/apply-migrations.ts appliedMigrations(). NOT changed (owned by C4/C6): apply-migrations.ts:40 still maps a NULL created_at to 0 (`Number(row.created_at ?? 0)`) before this module sees it, so end-to-end the NULL case stays unguarded until that line becomes `row.created_at === null ? null : Number(row.created_at)`. Co-Authored-By: Claude Opus 5.5 --- server/src/db/migration-reach.ts | 112 ++++++++++++++++++++++----- server/tests/migration-reach.test.ts | 75 +++++++++++++++++- 2 files changed, 165 insertions(+), 22 deletions(-) diff --git a/server/src/db/migration-reach.ts b/server/src/db/migration-reach.ts index 064754d4d..28cbe70c9 100644 --- a/server/src/db/migration-reach.ts +++ b/server/src/db/migration-reach.ts @@ -1,9 +1,11 @@ /** * Whether every migration in the folder can still be reached by the migrator, on THIS database. * - * WHAT THE MIGRATOR ACTUALLY DOES. `PgDialect.migrate` reads one row — the newest `created_at` in - * `drizzle.__drizzle_migrations` — and then runs a migration if, and only if, its journal `when` is - * greater than that number. It never looks at hashes, never looks at tags, and never notices that a + * WHAT THE MIGRATOR ACTUALLY DOES. `PgDialect.migrate` reads one row of + * `drizzle.__drizzle_migrations` — `order by created_at desc limit 1` — and then runs a migration + * if, and only if, its journal `when` is greater than `Number(created_at)` of that row. That row is + * not always the newest stamp: Postgres sorts NULLs FIRST in a descending order, so a single row + * with a NULL `created_at` is the row it reads, and `Number(null)` is 0. See `migratorHighWaterMark`. It never looks at hashes, never looks at tags, and never notices that a * migration it walked past was one it has never run. A migration whose `when` is at or below the * high-water mark is therefore not "pending": it is unreachable, forever, and the migrator will * report success without it. @@ -46,8 +48,14 @@ export type FolderMigration = { hash: string; }; -/** One row of `drizzle.__drizzle_migrations`. */ -export type AppliedMigration = { hash: string; createdAt: number }; +/** + * One row of `drizzle.__drizzle_migrations`. + * + * `createdAt` is nullable because the column is: drizzle creates it as a bare `bigint`, and a NULL + * there is not noise to be defaulted away — it is the row the migrator compares against. A reader + * that turns NULL into a number before it gets here hides exactly the case that matters. + */ +export type AppliedMigration = { hash: string; createdAt: number | null }; type Journal = { entries: { idx: number; tag: string; when: number }[] }; @@ -76,8 +84,40 @@ export async function readMigrationFolder( } /** - * The migrations this database can no longer reach: never applied, and stamped at or below the - * high-water mark the migrator will compare them against. + * The number the migrator will compare every journal `when` against, chosen the way it chooses it. + * + * `PgDialect.migrate` (drizzle-orm 0.45.2) runs `select … order by created_at desc limit 1` and then + * `Number(lastDbMigration.created_at) < migration.folderMillis`. Two things follow, and this + * function must reproduce both, because a guard that picks a different row than the migrator is a + * guard about some other migrator: + * + * - Postgres puts NULLs FIRST in a descending sort, so one NULL `created_at` is the row it reads, + * however many real stamps sit below it. `Math.max` over the stamps picks the largest real one + * and disagrees. + * - `Number(null)` is 0, so that row's mark is 0 and every migration in the folder is "after" it: + * the migrator re-runs the whole history and dies on the first `CREATE TYPE` that already exists. + * + * `undefined` for an empty table: the migrator then has no row and runs everything, correctly. + */ +export function migratorHighWaterMark( + applied: readonly AppliedMigration[], +): number | undefined { + if (applied.length === 0) return undefined; + if (applied.some((row) => row.createdAt === null)) return 0; + return Math.max(...applied.map((row) => row.createdAt ?? 0)); +} + +/** + * The migrations the migrator would get wrong on this database, judged against the row it reads. + * + * Two ways, both of which end with the migrator's word and the database disagreeing: + * + * - OUT OF REACH: never applied, and stamped at or below the mark — walked past forever, while the + * run reports success. + * - TO BE RE-RUN: already applied (its hash is recorded), yet stamped ABOVE the mark — run a second + * time. With honest stamps the mark is at least every applied migration's `when`, so this is + * empty; it fills when the row the migrator reads is not the newest stamp, which a NULL + * `created_at` guarantees. * * An empty `applied` is a database the migrator has never touched, where everything is reachable by * definition — a fresh database can never be in this trouble, and saying so explicitly keeps the @@ -87,14 +127,15 @@ export function migrationsOutOfReach( folder: readonly FolderMigration[], applied: readonly AppliedMigration[], ): FolderMigration[] { - if (applied.length === 0) return []; + const highWaterMark = migratorHighWaterMark(applied); + if (highWaterMark === undefined) return []; - const highWaterMark = Math.max(...applied.map((row) => row.createdAt)); const appliedHashes = new Set(applied.map((row) => row.hash)); - return folder.filter( - (migration) => - migration.when <= highWaterMark && !appliedHashes.has(migration.hash), + return folder.filter((migration) => + migration.when <= highWaterMark + ? !appliedHashes.has(migration.hash) + : appliedHashes.has(migration.hash), ); } @@ -110,7 +151,15 @@ export function outOfReachRefusal( folder: readonly FolderMigration[], applied: readonly AppliedMigration[], ): string { - const highWaterMark = Math.max(...applied.map((row) => row.createdAt)); + const highWaterMark = migratorHighWaterMark(applied) ?? 0; + const appliedHashes = new Set(applied.map((row) => row.hash)); + const skipped = outOfReach.filter( + (migration) => !appliedHashes.has(migration.hash), + ); + const rerun = outOfReach.filter((migration) => + appliedHashes.has(migration.hash), + ); + const nulls = applied.filter((row) => row.createdAt === null).length; const inFolder = new Set(folder.map((migration) => migration.hash)); /* * Rows this folder cannot account for. Not a failure on its own — a squashed or deleted migration @@ -119,18 +168,39 @@ export function outOfReachRefusal( */ const unknown = applied.filter((row) => !inFolder.has(row.hash)).length; const mark = - folder.find((migration) => migration.when === highWaterMark)?.tag ?? - "a migration this folder does not contain"; + nulls > 0 + ? "a row with no created_at, which the migrator reads first and counts as 0" + : (folder.find((migration) => migration.when === highWaterMark)?.tag ?? + "a migration this folder does not contain"); + const list = (migrations: readonly FolderMigration[]) => + migrations.map( + (migration) => ` - ${migration.tag} (stamped ${migration.when})`, + ); return [ - `This database has already recorded a migration stamped ${highWaterMark} (${mark}), and the migrator only runs migrations stamped after the newest one it finds.`, - `${outOfReach.length === 1 ? "This migration is" : `These ${outOfReach.length} migrations are`} stamped at or below that mark and ${outOfReach.length === 1 ? "has" : "have"} never been applied here, so running the migrator would skip ${outOfReach.length === 1 ? "it" : "them"} and report success over the schema ${outOfReach.length === 1 ? "it" : "they"} should have created:`, - ...outOfReach.map( - (migration) => ` - ${migration.tag} (stamped ${migration.when})`, - ), + `This database has already recorded a migration stamped ${highWaterMark} (${mark}), and the migrator only runs migrations stamped after the row it reads: the first one ordered by created_at descending.`, + ...(nulls === 0 + ? [] + : [ + `${nulls === 1 ? "1 applied row has" : `${nulls} applied rows have`} a NULL created_at. Postgres sorts NULLs first in a descending order, so that row, not the newest stamp, is the mark.`, + ]), + ...(skipped.length === 0 + ? [] + : [ + `${skipped.length === 1 ? "This migration is" : `These ${skipped.length} migrations are`} stamped at or below that mark and ${skipped.length === 1 ? "has" : "have"} never been applied here, so running the migrator would skip ${skipped.length === 1 ? "it" : "them"} and report success over the schema ${skipped.length === 1 ? "it" : "they"} should have created:`, + ...list(skipped), + ]), + ...(rerun.length === 0 + ? [] + : [ + `${rerun.length === 1 ? "This migration has" : `These ${rerun.length} migrations have`} already been applied here but ${rerun.length === 1 ? "is" : "are"} stamped above that mark, so running the migrator would run ${rerun.length === 1 ? "it" : "them"} a second time:`, + ...list(rerun), + ]), unknown === 0 ? "Every applied migration is still present in this folder." : `${unknown} applied ${unknown === 1 ? "migration is" : "migrations are"} not in this folder at all, which is what a renumbered or rewritten migration history looks like from here: this database ran a journal that no longer exists.`, - "Nothing has been applied. Recreate the database from empty if it is disposable (a development, preview or CI database usually is); otherwise apply the SQL above by hand and record each one with `insert into drizzle.__drizzle_migrations (hash, created_at) values (, )`, which leaves the high-water mark where it is.", + nulls > 0 + ? "Nothing has been applied. Give every row with a NULL created_at the journal `when` of the migration whose hash it records (`update drizzle.__drizzle_migrations set created_at = where hash = `), or delete a row that matches no migration, and run again." + : "Nothing has been applied. Recreate the database from empty if it is disposable (a development, preview or CI database usually is); otherwise apply the SQL above by hand and record each one with `insert into drizzle.__drizzle_migrations (hash, created_at) values (, )`, which leaves the high-water mark where it is.", ].join("\n"); } diff --git a/server/tests/migration-reach.test.ts b/server/tests/migration-reach.test.ts index 0006d0e68..3f0856e8c 100644 --- a/server/tests/migration-reach.test.ts +++ b/server/tests/migration-reach.test.ts @@ -7,6 +7,7 @@ import { type AppliedMigration, type FolderMigration, migrationsOutOfReach, + migratorHighWaterMark, outOfReachRefusal, readMigrationFolder, } from "../src/db/migration-reach"; @@ -34,7 +35,7 @@ const migration = ( hash: string, ): FolderMigration => ({ tag, when, hash }); -const applied = (hash: string, createdAt: number): AppliedMigration => ({ +const applied = (hash: string, createdAt: number | null): AppliedMigration => ({ hash, createdAt, }); @@ -134,6 +135,78 @@ describe("migrations out of reach", () => { }); }); +describe("the row the migrator reads", () => { + /* + * The guard is only as good as its agreement with the migrator about WHICH applied row is the + * mark. drizzle reads `order by created_at desc limit 1`, and Postgres sorts NULLs first in a + * descending order: one NULL `created_at` is the row it reads, `Number(null)` is 0, and every + * migration in the folder looks unapplied. `Math.max` over the stamps picks the newest real one + * instead, finds nothing out of reach, and waves through a run that dies re-creating the first + * type the database already has (`type "agent_type" already exists`). + */ + test("is a NULL created_at, however many real stamps sit below it", () => { + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_second", 2000, "b"), + ]; + const database = [ + applied("a", 1000), + applied("b", 2000), + applied("hand-written", null), + ]; + + expect(migratorHighWaterMark(database)).toBe(0); + expect(migrationsOutOfReach(folder, database)).toEqual(folder); + }); + + test("is the newest stamp when no created_at is NULL", () => { + expect( + migratorHighWaterMark([applied("a", 1000), applied("b", 2000)]), + ).toBe(2000); + }); + + test("does not exist on a database the migrator has never touched", () => { + expect(migratorHighWaterMark([])).toBeUndefined(); + }); + + test("refuses every applied migration the shipped folder would run a second time", async () => { + const folder = await readMigrationFolder( + new URL("../drizzle/", import.meta.url).pathname, + ); + const database = [ + ...folder.map((entry) => applied(entry.hash, entry.when)), + applied("hand-written", null), + ]; + + expect( + migrationsOutOfReach(folder, database).map((entry) => entry.tag), + ).toEqual(folder.map((entry) => entry.tag)); + }); + + test("says that a NULL created_at is the mark, and names what would run twice", () => { + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_second", 2000, "b"), + ]; + const database = [ + applied("a", 1000), + applied("b", 2000), + applied("hand-written", null), + ]; + const text = outOfReachRefusal( + migrationsOutOfReach(folder, database), + folder, + database, + ); + + expect(text).toContain("1 applied row has a NULL created_at"); + expect(text).toContain("would run them a second time"); + expect(text).toContain("0000_first (stamped 1000)"); + expect(text).toContain("0001_second (stamped 2000)"); + expect(text).not.toContain("would skip"); + }); +}); + describe("the refusal", () => { const folder = [ migration("0000_first", 1000, "a"), From e5bc7cebdeb1b5ae9f53d6573d0851c1fd560548 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:22:30 +0200 Subject: [PATCH 187/232] fix(tests): resolve every type-only import in server/tests that pointed nowhere bun erases type imports, so an `import type` naming a missing module or an unexported symbol never fails a test run: the binding silently becomes `any` and the annotation it feeds checks nothing. Swept with the project's own `tsc -p server/tsconfig.tests.json` (bun cannot see this class at all) and repointed every instance at the module that actually exports the symbol: - support/constraints.ts: `../src/db/client` -> `../../src/db/client` (shared helper; `Database` was `any` for every caller of `checkRoster`) - brokered-app-provider.integration.test.ts, store-scheme-sites.test.ts: `ComposioBroker` from `plugins/composio` -> `plugins/broker` - component-grants-skills-validation, plugin-call-args-validation, plugin-grants-validation, plugin-servers-validation: `BotAccessCheck` from `plugins/routes` (local, unexported) -> `agents/profile-policy` - server-side-tools.integration.test.ts: `ActionPolicy` from the nonexistent `policy/engine` -> `computer/policy` - agent-handoff.test.ts: `AgentProfile` from `profile-store` (local re-import, unexported) -> `profile-types` - headless-components.test.ts: `typeof import("react")` / `"react-dom/server"` do not resolve from `server`; take the types from the app's install, the same place the values are `createRequire`d from Surfaced, not softened: with `ComposioBroker` real, both broker stubs fail TS2739 (missing `connectionFields`, `connectWithFields`, `revokeAccount`). That is pre-existing debt the broken import hid; recorded in the baseline. Baseline 276 -> 267: 11 import errors removed, 2 TS2739 surfaced. assertion-shape ALLOW: three entries' line numbers moved with the edits (agent-handoff 368->366, 622->620; headless-components 192->197). No production code changed; no signatures changed, so no call sites. Co-Authored-By: Claude Opus 5.5 --- server/tests/agent-handoff.test.ts | 6 ++---- server/tests/assertion-shape.test.ts | 6 +++--- .../tests/brokered-app-provider.integration.test.ts | 2 +- .../component-grants-skills-validation.test.ts | 2 +- server/tests/headless-components.test.ts | 9 +++++++-- server/tests/plugin-call-args-validation.test.ts | 2 +- server/tests/plugin-grants-validation.test.ts | 2 +- server/tests/plugin-servers-validation.test.ts | 2 +- server/tests/server-side-tools.integration.test.ts | 2 +- server/tests/store-scheme-sites.test.ts | 2 +- server/tests/support/constraints.ts | 2 +- server/tests/typecheck-baseline.txt | 13 ++----------- 12 files changed, 22 insertions(+), 28 deletions(-) diff --git a/server/tests/agent-handoff.test.ts b/server/tests/agent-handoff.test.ts index 99a1cd5ff..97a98e6d9 100644 --- a/server/tests/agent-handoff.test.ts +++ b/server/tests/agent-handoff.test.ts @@ -4,10 +4,8 @@ import { HANDOFF_KIND, type HandoffCaps, } from "../src/agents/handoff"; -import type { - AgentProfile, - AgentProfileStore, -} from "../src/agents/profile-store"; +import type { AgentProfileStore } from "../src/agents/profile-store"; +import type { AgentProfile } from "../src/agents/profile-types"; import type { AuditStore } from "../src/audit"; import type { WorkQueue } from "../src/work/queue"; diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 78107fb71..7b7df1424 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -625,7 +625,7 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/agent-handoff.test.ts", - line: 368, + line: 366, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(plain.events[0]?.initiator).toBe(undefined);", @@ -723,7 +723,7 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/headless-components.test.ts", - line: 192, + line: 197, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(bar?.hidden).toBeUndefined();", @@ -814,7 +814,7 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/agent-handoff.test.ts", - line: 622, + line: 620, shape: "c", finding: "NEW (round 2 did not find this)", why: ').toEqual([ { eventType: "agent.handoff_offered", reason: undefined }, { eventType: "agent.handoff_refused", r', diff --git a/server/tests/brokered-app-provider.integration.test.ts b/server/tests/brokered-app-provider.integration.test.ts index b8f32bf0f..38cb031c1 100644 --- a/server/tests/brokered-app-provider.integration.test.ts +++ b/server/tests/brokered-app-provider.integration.test.ts @@ -12,7 +12,7 @@ import { createDatabase } from "../src/db/client"; import { brokeredConnections, mcpServers } from "../src/db/schema"; import type { BrokerConnection } from "../src/plugins/broker"; import type { BrokerProvider } from "../src/plugins/access"; -import type { ComposioBroker } from "../src/plugins/composio"; +import type { ComposioBroker } from "../src/plugins/broker"; import { CustomServerRefusedError, createPluginStore, diff --git a/server/tests/component-grants-skills-validation.test.ts b/server/tests/component-grants-skills-validation.test.ts index c8c89050a..f616bd604 100644 --- a/server/tests/component-grants-skills-validation.test.ts +++ b/server/tests/component-grants-skills-validation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; import type { AppVariables } from "../src/auth/guards"; -import type { BotAccessCheck } from "../src/plugins/routes"; +import type { BotAccessCheck } from "../src/agents/profile-policy"; import { createPluginRoutes } from "../src/plugins/routes"; import type { PluginStore } from "../src/plugins/store"; import { createComponentRoutes } from "../src/components/routes"; diff --git a/server/tests/headless-components.test.ts b/server/tests/headless-components.test.ts index d4f6aff9b..c994d1a6d 100644 --- a/server/tests/headless-components.test.ts +++ b/server/tests/headless-components.test.ts @@ -138,10 +138,15 @@ describe("headless components mirror the compiled gallery", () => { test("arguments a headless turn accepts draw the real chart with the app's renderer", async () => { const appRequire = createRequire(join(import.meta.dir, "../../app/")); - const { createElement } = appRequire("react") as typeof import("react"); + // The TYPES come from the app's install for the same reason the values do: `server` has no + // React. A bare `typeof import("react")` does not resolve from here, and an unresolved type + // import is not an error bun can see — it silently makes both casts `any`. + const { createElement } = appRequire( + "react", + ) as typeof import("../../app/node_modules/@types/react"); const { renderToStaticMarkup } = appRequire( "react-dom/server", - ) as typeof import("react-dom/server"); + ) as typeof import("../../app/node_modules/@types/react-dom/server"); const args = { title: "Open issues by team", points: [ diff --git a/server/tests/plugin-call-args-validation.test.ts b/server/tests/plugin-call-args-validation.test.ts index 5afb632d2..fe5fc9ce5 100644 --- a/server/tests/plugin-call-args-validation.test.ts +++ b/server/tests/plugin-call-args-validation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; import type { AppVariables } from "../src/auth/guards"; -import type { BotAccessCheck } from "../src/plugins/routes"; +import type { BotAccessCheck } from "../src/agents/profile-policy"; import { createPluginRoutes } from "../src/plugins/routes"; import type { PluginStore } from "../src/plugins/store"; diff --git a/server/tests/plugin-grants-validation.test.ts b/server/tests/plugin-grants-validation.test.ts index 43ee18406..8038b0b30 100644 --- a/server/tests/plugin-grants-validation.test.ts +++ b/server/tests/plugin-grants-validation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import type { AppVariables } from "../src/auth/guards"; -import type { BotAccessCheck } from "../src/plugins/routes"; +import type { BotAccessCheck } from "../src/agents/profile-policy"; import { createPluginRoutes } from "../src/plugins/routes"; import type { PluginStore } from "../src/plugins/store"; diff --git a/server/tests/plugin-servers-validation.test.ts b/server/tests/plugin-servers-validation.test.ts index 117c51b61..7a926844a 100644 --- a/server/tests/plugin-servers-validation.test.ts +++ b/server/tests/plugin-servers-validation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import type { AppVariables } from "../src/auth/guards"; -import type { BotAccessCheck } from "../src/plugins/routes"; +import type { BotAccessCheck } from "../src/agents/profile-policy"; import { createPluginRoutes } from "../src/plugins/routes"; import type { PluginStore } from "../src/plugins/store"; diff --git a/server/tests/server-side-tools.integration.test.ts b/server/tests/server-side-tools.integration.test.ts index a47abf7f5..a6245a3dd 100644 --- a/server/tests/server-side-tools.integration.test.ts +++ b/server/tests/server-side-tools.integration.test.ts @@ -13,7 +13,7 @@ import { } from "../src/db/schema"; import { createPluginStore } from "../src/plugins/store"; import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools"; -import type { ActionPolicy } from "../src/policy/engine"; +import type { ActionPolicy } from "../src/computer/policy"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; /** diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts index a19b51793..a3362dc12 100644 --- a/server/tests/store-scheme-sites.test.ts +++ b/server/tests/store-scheme-sites.test.ts @@ -21,7 +21,7 @@ import { } from "../src/db/schema"; import { BROKERED_PROFILE, type BrokerProvider } from "../src/plugins/access"; import type { BrokerConnection } from "../src/plugins/broker"; -import type { ComposioBroker } from "../src/plugins/composio"; +import type { ComposioBroker } from "../src/plugins/broker"; import { CustomServerRefusedError, createPluginStore, diff --git a/server/tests/support/constraints.ts b/server/tests/support/constraints.ts index 66e0955a9..93d16da63 100644 --- a/server/tests/support/constraints.ts +++ b/server/tests/support/constraints.ts @@ -12,7 +12,7 @@ // whatever spelling the migration used to write it, which also means the comparison survives // `IN (...)` being recorded as `= ANY (ARRAY[...])`. import { sql } from "drizzle-orm"; -import type { createDatabase } from "../src/db/client"; +import type { createDatabase } from "../../src/db/client"; type Database = ReturnType; diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index be4079837..8c50a8180 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -33,7 +33,6 @@ server/tests/agent-handoff-tool.test.ts:107: error TS2345: Argument of type '{ d server/tests/agent-handoff-tool.test.ts:78: error TS2345: Argument of type '{ desk: HandoffDesk; from: { depth: number; botId: string; actorId: string; runId: string; threadId: string; }; hasSomebodyToAsk: true; maxDepth: number; }' is not assignable to parameter of type '{ desk: HandoffDesk; from: RunAssertion; hasSomebodyToAsk: boolean; maxDepth: number; maxPerRun: number; approvalGate?: ApprovalGate | undefined; privateShare?: PrivateShareGate | undefined; }'. server/tests/agent-handoff.integration.test.ts:34: error TS2554: Expected 2-3 arguments, but got 1. server/tests/agent-handoff.integration.test.ts:80: error TS2769: No overload matches this call. -server/tests/agent-handoff.test.ts:8: error TS2459: Module '"../src/agents/profile-store"' declares 'AgentProfile' locally, but it is not exported. server/tests/agent-profile-policy.test.ts:14: error TS2322: Type '{ id: string; name: string; title: string; roleDescription: string; avatarSeed: string; visibility: AgentVisibility; ownerUserId: string | null; ... 7 more ...; hasCallbackToken?: boolean | undefined; }' is not assignable to type 'AgentProfile'. server/tests/agent-routes.test.ts:36: error TS2322: Type '{ id: string; name: string; title: string; roleDescription: string; avatarSeed: string; visibility: AgentVisibility; ownerUserId: string | null; ... 7 more ...; hasCallbackToken?: boolean | undefined; }' is not assignable to type 'AgentProfile'. server/tests/agent-routes.test.ts:563: error TS2769: No overload matches this call. @@ -70,7 +69,7 @@ server/tests/bot-lifecycle-audit.test.ts:301: error TS2345: Argument of type '{ server/tests/bot-lifecycle-audit.test.ts:315: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. server/tests/bot-lifecycle-audit.test.ts:326: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. server/tests/bot-lifecycle-audit.test.ts:335: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. -server/tests/brokered-app-provider.integration.test.ts:15: error TS2305: Module '"../src/plugins/composio"' has no exported member 'ComposioBroker'. +server/tests/brokered-app-provider.integration.test.ts:51: error TS2739: Type '{ grantIsLocal: true; authProviderOf: () => Promise; authorizations: null; grantsFor: () => Promise; listApps: () => Promise; ensureAuthConfig: () => Promise<...>; ... 4 more ...; accountName: () => Promise<...>; }' is missing the following properties from type 'ConnectedAppBroker': connectionFields, connectWithFields, revokeAccount server/tests/brokered-app-provider.integration.test.ts:72: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/channel-routes.test.ts:385: error TS2769: No overload matches this call. server/tests/channel-routes.test.ts:86: error TS2322: Type '(receivedActor: AgentActor, id: string) => Promise' is not assignable to type '(actor: AgentActor, channelId: string) => Promise'. @@ -80,7 +79,6 @@ server/tests/component-catalogue-entries.test.ts:22: error TS2769: No overload m server/tests/component-catalogue.test.ts:21: error TS2769: No overload matches this call. server/tests/component-decision-functions.test.ts:32: error TS2769: No overload matches this call. server/tests/component-decision.test.ts:30: error TS2769: No overload matches this call. -server/tests/component-grants-skills-validation.test.ts:5: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. server/tests/composio-connection-kinds.test.ts:1908: error TS2769: No overload matches this call. server/tests/composio-connection-kinds.test.ts:1910: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"key" | "none" | "consent" | "unreadable", Cell>'. server/tests/composio-connection-kinds.test.ts:1924: error TS2769: No overload matches this call. @@ -158,8 +156,6 @@ server/tests/headless-components.integration.test.ts:54: error TS7031: Binding e server/tests/headless-components.test.ts:104: error TS7006: Parameter 'entry' implicitly has an 'any' type. server/tests/headless-components.test.ts:10: error TS6142: Module '../../app/src/components/gallery/quote' was resolved to 'app/src/components/gallery/quote.tsx', but '--jsx' is not set. server/tests/headless-components.test.ts:11: error TS6142: Module '../../app/src/components/gallery/table' was resolved to 'app/src/components/gallery/table.tsx', but '--jsx' is not set. -server/tests/headless-components.test.ts:141: error TS2307: Cannot find module 'react' or its corresponding type declarations. -server/tests/headless-components.test.ts:144: error TS2307: Cannot find module 'react-dom/server' or its corresponding type declarations. server/tests/headless-components.test.ts:5: error TS6142: Module '../../app/src/components/gallery/activity' was resolved to 'app/src/components/gallery/activity.tsx', but '--jsx' is not set. server/tests/headless-components.test.ts:6: error TS6142: Module '../../app/src/components/gallery/cards' was resolved to 'app/src/components/gallery/cards.tsx', but '--jsx' is not set. server/tests/headless-components.test.ts:7: error TS6142: Module '../../app/src/components/gallery/charts' was resolved to 'app/src/components/gallery/charts.tsx', but '--jsx' is not set. @@ -184,17 +180,14 @@ server/tests/passwords-sign-in.test.ts:864: error TS2353: Object literal may onl server/tests/people-routes.test.ts:84: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. server/tests/plan-model.test.ts:156: error TS2769: No overload matches this call. server/tests/plan-model.test.ts:271: error TS2345: Argument of type '(id: string) => Promise<{ name: string; description: string; parameters: ZodObject<{ query: ZodString; }, $strip>; ref: { kind: string; serverId: string; toolName: string; }; execute: (args: unknown) => Promise<...>; }[]>' is not assignable to parameter of type 'LoadToolsForBot'. -server/tests/plugin-call-args-validation.test.ts:5: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. server/tests/plugin-catalogue.test.ts:92: error TS2531: Object is possibly 'null'. server/tests/plugin-curated-credential.integration.test.ts:37: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/plugin-grants-validation.test.ts:4: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. server/tests/plugin-oauth-callback.test.ts:157: error TS2769: No overload matches this call. server/tests/plugin-routes.integration.test.ts:32: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/plugin-routes.test.ts:1071: error TS2769: No overload matches this call. server/tests/plugin-routes.test.ts:125: error TS2739: Type '{ id: string; title: string; vendor: string; url: string; summary: string; docsUrl: string; provenance: string; hasCredential: false; toolsRefreshedAt: null; lastError: null; addedBy: null; dynamicClient: false; authScheme: null; tools: never[]; withdrawn: never[]; }' is missing the following properties from type 'ServerRecord': logo, accountMode, sharedVendorConnected server/tests/plugin-routes.test.ts:1775: error TS2322: Type '(serverId: string) => Promise<{ id: string; title: string; url: string; authScheme: string | null; accountMode?: "shared"; } | undefined>' is not assignable to type '(serverId: string) => Promise'. server/tests/plugin-routes.test.ts:1784: error TS2322: Type '(serverId: string, actorId: string) => Promise<{ refusal: string; readonly ref?: undefined; title?: undefined; mode?: undefined; } | { ref: { holder: "deployment"; vendorUserId: string; userId?: undefined; }; title: string; mode: "shared"; refusal?: undefined; } | { ...; }>' is not assignable to type '(serverId: string, actorId: string) => Promise<{ ref: AccountRef; title: string; mode: "personal" | "shared"; appId: string; } | { refusal: string; }>'. -server/tests/plugin-servers-validation.test.ts:4: error TS2459: Module '"../src/plugins/routes"' declares 'BotAccessCheck' locally, but it is not exported. server/tests/plugin-store.integration.test.ts:165: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/plugin-store.integration.test.ts:4072: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/plugin-store.integration.test.ts:4979: error TS2339: Property 'payload' does not exist on type '{ eventType: string; targetId?: string | undefined; }'. @@ -252,7 +245,6 @@ server/tests/schema.test.ts:202: error TS2345: Argument of type '(table: T) => T["_"]["name"]' is not assignable to parameter of type '(value: PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ......'. server/tests/schema.test.ts:206: error TS2345: Argument of type 'PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ...; update...' is not assignable to parameter of type 'PgTable'. server/tests/schema.test.ts:208: error TS2345: Argument of type 'PgTableWithColumns<{ name: "capability_settings"; schema: undefined; columns: { scopeKind: PgColumn<{ name: "scope_kind"; tableName: "capability_settings"; dataType: "string"; columnType: "PgText"; data: "organization" | "role" | "group"; ... 9 more ...; generated: undefined; }, {}, { ...; }>; ... 4 more ...; update...' is not assignable to parameter of type 'Table>>'. -server/tests/server-side-tools.integration.test.ts:16: error TS2307: Cannot find module '../src/policy/engine' or its corresponding type declarations. server/tests/server-side-tools.integration.test.ts:47: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/settings-instructions-routes.test.ts:236: error TS2339: Property 'then' does not exist on type 'Response | Promise'. server/tests/settings-instructions-routes.test.ts:236: error TS7006: Parameter 'response' implicitly has an 'any' type. @@ -265,9 +257,8 @@ server/tests/settings-instructions-routes.test.ts:314: error TS7006: Parameter ' server/tests/skill-ownership.integration.test.ts:29: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/skill-tools.integration.test.ts:35: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/skill-uninstall-grants.integration.test.ts:26: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/store-scheme-sites.test.ts:24: error TS2305: Module '"../src/plugins/composio"' has no exported member 'ComposioBroker'. +server/tests/store-scheme-sites.test.ts:62: error TS2739: Type '{ grantIsLocal: true; authProviderOf: () => Promise; authorizations: null; grantsFor: () => Promise; listApps: () => Promise; ensureAuthConfig: () => Promise<...>; ... 4 more ...; accountName: () => Promise<...>; }' is missing the following properties from type 'ConnectedAppBroker': connectionFields, connectWithFields, revokeAccount server/tests/store-scheme-sites.test.ts:82: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/support/constraints.ts:15: error TS2307: Cannot find module '../src/db/client' or its corresponding type declarations. server/tests/tenant-package.test.ts:1440: error TS2698: Spread types may only be created from object types. server/tests/tenant-package.test.ts:1518: error TS2698: Spread types may only be created from object types. server/tests/thread-routes.test.ts:22: error TS2769: No overload matches this call. From e1f7a0ea3fd8537105ca7dd098d95010f642c7ca Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:25:37 +0200 Subject: [PATCH 188/232] fix(db): serialise the reach check and the migrate under one lock applyMigrations read drizzle.__drizzle_migrations for the reach check, then handed off to drizzle's migrator, which reads the same table again for its high-water mark. A rival migrator with a different journal could commit a higher stamp between those two reads: the check passed, the migrator walked past a pending migration, and the run reported success over a missing table -- the outcome the reach check exists to prevent. Both now run inside one transaction that first takes pg_advisory_xact_lock(hashtextextended('openbot:apply-migrations', 0)). Drizzle's migrator is driven through the open transaction via a three-member adapter (options from the pool, unsafe and savepoint from the transaction), so its own transaction becomes a savepoint inside the lock. The lock is released by commit, rollback (including the refusal), or connection loss. Red-green: new integration case pauses our run at its k-th database call for every k and starts a rival migrator there. Base: red at call 3 (from_main missing after a reported success). Planted lock removal with the transaction kept: red at call 4. Fixed: green, 10 pause points. Call sites of applyMigrations (signature unchanged): - server/scripts/migrate.ts (image s6, Helm Job, compose, CI, db:migrate) - server/tests/migration-reach.integration.test.ts appliedMigrations (file-private) now takes TransactionSql; its only caller is applyMigrations. Co-Authored-By: Claude Opus 5.5 --- server/src/db/apply-migrations.ts | 82 +++++++- .../tests/migration-reach.integration.test.ts | 190 ++++++++++++++++++ 2 files changed, 262 insertions(+), 10 deletions(-) diff --git a/server/src/db/apply-migrations.ts b/server/src/db/apply-migrations.ts index 64daa44e0..6838f7410 100644 --- a/server/src/db/apply-migrations.ts +++ b/server/src/db/apply-migrations.ts @@ -11,7 +11,7 @@ */ import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; -import type { Sql } from "postgres"; +import type { Sql, TransactionSql } from "postgres"; import { type AppliedMigration, migrationsOutOfReach, @@ -26,7 +26,9 @@ import { * `drizzle` schema, and selecting from a table that is not there would be an error indistinguishable * from a database that cannot be read. */ -async function appliedMigrations(client: Sql): Promise { +async function appliedMigrations( + client: TransactionSql, +): Promise { const [present] = await client<{ table: string | null }[]>` select to_regclass('drizzle.__drizzle_migrations')::text as table `; @@ -49,19 +51,79 @@ export class MigrationsOutOfReachError extends Error { } } +/** + * One lock per database for every run of this function, whatever journal it carries. + * + * A constant rather than anything derived from the folder: the runs that must not overlap are + * precisely the ones whose folders differ. Advisory locks are scoped to the database, so two + * databases on one server never wait on each other. + */ +const MIGRATION_LOCK = "openbot:apply-migrations"; + +/** + * Check, then migrate, with nothing able to commit in between. + * + * THE GAP THIS CLOSES. The reach check reads `drizzle.__drizzle_migrations`; the migrator then reads + * it AGAIN to find its high-water mark. Run as two steps, a second migrator could commit between + * those reads — and if it carries a different journal (an operator's `db:migrate` from a checkout, + * or a second release's Job started while the first is still running) its stamp can land above a + * migration this run still has pending. The check passed before that stamp existed; the migrator + * reads it afterwards, walks past the migration, and the run reports success over a table that is + * not there: the exact outcome the check exists to prevent, arrived at through the gap between the + * two reads. A rival with the SAME journal (a Job pod the controller started twice) is the lesser + * case: both read the same mark, both run the same SQL, and one dies on a duplicate `CREATE`. + * + * So both run inside one transaction that first takes a transaction-scoped advisory lock. Every + * other run of this function queues behind it and, once admitted, checks a table the previous run + * has already committed. The lock goes with the transaction — on commit, on the refusal's rollback, + * or when the connection dies — so there is no unlock to forget and no session to leak it into. + * + * WHAT IT DOES NOT COVER. A migrator that is not this function (`drizzle-kit migrate`, or an image + * built before this lock) does not take it, and can still interleave. Every path this repository + * runs goes through here; see `scripts/migrate.ts`. + */ export async function applyMigrations( client: Sql, migrationsFolder: string, ): Promise { const folder = await readMigrationFolder(migrationsFolder); - const applied = await appliedMigrations(client); - const outOfReach = migrationsOutOfReach(folder, applied); - if (outOfReach.length > 0) { - throw new MigrationsOutOfReachError( - outOfReachRefusal(outOfReach, folder, applied), - ); - } + await client.begin(async (tx) => { + await tx`select pg_advisory_xact_lock(hashtextextended(${MIGRATION_LOCK}, 0))`; + + const applied = await appliedMigrations(tx); + const outOfReach = migrationsOutOfReach(folder, applied); + if (outOfReach.length > 0) { + throw new MigrationsOutOfReachError( + outOfReachRefusal(outOfReach, folder, applied), + ); + } + + await migrate(drizzle(withinTransaction(client, tx)), { migrationsFolder }); + }); +} - await migrate(drizzle(client), { migrationsFolder }); +/** + * The open transaction, in the shape `drizzle()` takes a client in. + * + * Drizzle's migrator reads its high-water mark and runs the migrations through the client it is + * given, and opens its own transaction with `client.begin`. A postgres.js transaction has no + * `begin` — only `savepoint` — and no `options`, which `drizzle()` writes its type parsers into. So + * this hands it exactly those three things: `unsafe` and `savepoint` from the transaction, so every + * statement the migrator sends is inside the lock, and `options` from the pool the transaction + * belongs to, which is where postgres.js reads parsers from. Its transaction becomes a savepoint in + * ours, with the same all-or-nothing effect it always had. + * + * If a later drizzle-orm reaches for anything else on its client, it fails here with a TypeError at + * the first migration rather than migrating outside the lock — and + * `tests/migration-reach.integration.test.ts` runs exactly this path. + */ +function withinTransaction(client: Sql, tx: TransactionSql): Sql { + const adapter = { + options: client.options, + unsafe: tx.unsafe.bind(tx), + begin: (fn: (sql: TransactionSql) => T | Promise) => tx.savepoint(fn), + }; + // The cast is the claim above: drizzle uses these three members and no others. + return adapter as unknown as Sql; } diff --git a/server/tests/migration-reach.integration.test.ts b/server/tests/migration-reach.integration.test.ts index 90cbb4688..c2bdd7e67 100644 --- a/server/tests/migration-reach.integration.test.ts +++ b/server/tests/migration-reach.integration.test.ts @@ -204,3 +204,193 @@ describe("an ordinary database", () => { } }); }); + +/** + * Two migrators against one database, interleaved at every point one of them talks to it. + * + * The reach check is a read, and the migrator's own decision is a second, later read of the same + * table. If anything can commit between them, the check's verdict is about a database that no + * longer exists by the time the migrator acts on it. The case that matters is a rival running a + * DIFFERENT journal — an operator's `db:migrate` from a checkout, a second release's Job, a Job pod + * the controller started twice while an older one is still running — that records a stamp above a + * migration this run still has pending. The check passed before that stamp existed; the migrator + * reads it afterwards and walks past the migration, and the run reports success over a table that is + * not there. That is the exact outcome the reach check exists to prevent, arrived at through the gap + * between the two reads. + * + * So the rival is started at the k-th database call this run makes, for every k, through a client + * that pauses there. The pause ends when the rival has either finished or is visibly waiting on a + * lock — waiting for it to FINISH would deadlock against a migrator that correctly holds one. + * Whatever order results, a run that resolves must have applied everything in its folder: being + * refused, or failing loudly, is an acceptable outcome; reporting success over a skipped migration + * is not. + */ +describe("a migrator racing another migrator with a different journal", () => { + const fromMain: Entry = { + tag: "0001_from_main", + when: 2_000_000, + sql: 'CREATE TABLE "from_main" ("id" text PRIMARY KEY);\n', + }; + const rivalOnly: Entry = { + tag: "0001_rival", + when: 3_000_000, + sql: 'CREATE TABLE "rival" ("id" text PRIMARY KEY);\n', + }; + + type Gate = { count: number; at: number; hit: Promise | undefined }; + + /** + * `client`, except that its `at`-th call into the database first waits for `pause()`. + * + * Every way a caller reaches the server is counted — a tagged query, `unsafe`, `begin`, + * `savepoint`, and the connection a `begin` or `savepoint` hands to its callback — so a pause can + * land between ANY two statements, including those issued inside a transaction. + */ + function pausing( + client: T, + gate: Gate, + pause: () => Promise, + ): T { + const before = (): Promise => { + gate.count += 1; + if (gate.count !== gate.at) return Promise.resolve(); + gate.hit = pause(); + return gate.hit; + }; + /* + * A lazy thenable: the query is built, and so sent, only once the pause is over, and only in + * the form — awaited, or `.values()` — that the caller actually asked for. + */ + const deferred = (build: () => unknown) => { + const waited = before(); + return { + // biome-ignore lint/suspicious/noThenProperty: it stands in for a query, which is awaitable + then: ( + resolve: (value: unknown) => unknown, + reject: (error: unknown) => unknown, + ) => waited.then(() => build()).then(resolve, reject), + values: () => + waited.then(() => + (build() as { values: () => Promise }).values(), + ), + }; + }; + return new Proxy(client, { + apply: (target, _this, args) => + deferred(() => + Reflect.apply( + target as (...a: unknown[]) => unknown, + undefined, + args, + ), + ), + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver) as unknown; + if (typeof value !== "function") return value; + const method = value as (...args: unknown[]) => unknown; + if (property === "unsafe") { + return (...args: unknown[]) => deferred(() => method(...args)); + } + if (property === "begin" || property === "savepoint") { + return (...args: unknown[]) => { + const fn = args.pop() as (sql: unknown) => unknown; + return before().then(() => + method(...args, (inner: object) => + fn(pausing(inner, gate, pause)), + ), + ); + }; + } + return value; + }, + }); + } + + test("never reports success over a migration a rival's stamp put out of its reach", async () => { + let pausesExercised = 0; + for (let at = 1; ; at += 1) { + const url = await throwawayDatabase(); + const setup = postgres(url, { max: 1, onnotice: () => {} }); + const ours = postgres(url, { max: 1, onnotice: () => {} }); + const rival = postgres(url, { + max: 1, + onnotice: () => {}, + connection: { application_name: "migration-rival" }, + }); + try { + await applyMigrations(setup, await folderOf([first])); + const ourFolder = await folderOf([first, fromMain]); + const rivalFolder = await folderOf([first, rivalOnly]); + + let rivalRun: Promise | undefined; + let rivalSettled = false; + const gate: Gate = { count: 0, at, hit: undefined }; + const pause = async () => { + rivalRun = applyMigrations(rival, rivalFolder).then( + () => { + rivalSettled = true; + return "applied" as const; + }, + (error: unknown) => { + rivalSettled = true; + return error; + }, + ); + // Finished, or parked behind a lock: either way it has had every chance to get in first. + const deadline = Date.now() + 10_000; + while (!rivalSettled) { + const [row] = await setup<{ waiting: boolean }[]>` + select exists ( + select 1 from pg_stat_activity + where application_name = 'migration-rival' + and wait_event_type = 'Lock' + ) as waiting + `; + if (row?.waiting) break; + if (Date.now() > deadline) { + throw new Error( + `the rival migrator neither finished nor waited on a lock (paused at call ${at})`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }; + + const outcome = await applyMigrations( + pausing(ours, gate, pause), + ourFolder, + ).then( + () => "applied" as const, + (error: unknown) => error, + ); + if (gate.hit === undefined) break; // every call this run makes has had a rival in front of it + await gate.hit; + const rivalOutcome = await rivalRun; + pausesExercised += 1; + + // Refused or failed is acceptable. Applied means applied. + if (outcome === "applied") { + expect({ + pausedAtCall: at, + fromMainExists: await exists(setup, "from_main"), + }).toEqual({ pausedAtCall: at, fromMainExists: true }); + } + if (rivalOutcome === "applied") { + expect({ + pausedAtCall: at, + rivalExists: await exists(setup, "rival"), + }).toEqual({ pausedAtCall: at, rivalExists: true }); + } + } finally { + await ours.end({ timeout: 5 }); + await rival.end({ timeout: 5 }); + await setup.end({ timeout: 5 }); + } + } + /* + * A harness whose pause never landed would pass vacuously. Every run makes at least the two + * reads of the check and the migrator's own statements; each of them must have been tried. + */ + expect(pausesExercised).toBeGreaterThanOrEqual(4); + }, 120_000); +}); From 2d9e233cbbf1d0761d9588aac0f9234de5e118fd Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:26:44 +0200 Subject: [PATCH 189/232] fix(tests): close every egress path the test network guard left open (C7) The preload guard patched only globalThis.fetch and http(s).request. Measured at aa877583 through a planted call to example.com: https.get, http.get, Bun.fetch, `import { get } from "node:https"`, http.ClientRequest, the built-in undici (fetch and request), net, tls, Bun.connect, WebSocket and dgram all connected (Bun.fetch: STATUS 200; https.get: STATUS 200), with zero refusals recorded. Now closed, each at the lowest layer JavaScript reaches: - Bun.fetch is read-only and non-configurable, and undici captured the native fetch earlier; both honour the proxy env, so HTTP(S)_PROXY points at a refusing loopback listener (NO_PROXY keeps loopback direct), which records the target and answers 403 without dialling onward. - http/https request + get + ClientRequest replaced, then syncBuiltinESMExports() so named imports see the guarded functions. - net.Socket.prototype.connect (covers net.connect, createConnection, tls.connect), Bun.connect, http2.connect, WebSocket (global, http, ws, undici), dgram send/connect, Bun.udpSocket. Raw sockets may also reach the TEST_DATABASE_URL / DATABASE_URL host. Not closed, stated in the file: Bun.SQL, Bun.S3Client/redis, fetch with an explicit proxy/unix option, child processes, Workers, DNS lookups. The test is now scoped: one case per entry point, each asserting the caller's outcome AND that the guard recorded exactly that target during that call (the record is process-wide, so the old whole-record check could be satisfied by an earlier test). Loopback HTTP/socket and the configured DB host are pinned as allowed. Call sites: the guard exports nothing; it is imported only by server/scripts/test-preload.ts. globalThis.openbotTestNetworkRefusals is read only by server/tests/test-network-guard.test.ts. Co-Authored-By: Claude Opus 5.5 --- server/scripts/test-network-guard.ts | 323 ++++++++++++++++++++--- server/tests/test-network-guard.test.ts | 330 ++++++++++++++++++++---- 2 files changed, 558 insertions(+), 95 deletions(-) diff --git a/server/scripts/test-network-guard.ts b/server/scripts/test-network-guard.ts index 1474526c8..17e70cea8 100644 --- a/server/scripts/test-network-guard.ts +++ b/server/scripts/test-network-guard.ts @@ -16,16 +16,38 @@ * — the file reads as if it stubs — which is why the floor is here, in a preload that runs before * any test file is imported and therefore before any vendor client can capture a transport. * - * WHAT IS REFUSED: an HTTP request to anything that is not loopback. Loopback is allowed because - * the suite's own servers are loopback — `Bun.serve`, the route tests' ephemeral ports — and a test - * talking to a server it started is not talking to anyone. + * WHAT IS REFUSED: a connection to anything that is not loopback, through every way this runtime + * (Bun 1.4) offers a test file of opening one. Patching `globalThis.fetch` and `http.request` alone + * is not that: an earlier version of this file did only that, and `https.get`, `Bun.fetch`, the + * named imports `import { get } from "node:https"`, the built-in `undici`, `net`, `tls`, + * `Bun.connect`, `WebSocket` and `dgram` all still reached example.com. Each one is closed below, + * at the lowest layer JavaScript can reach for it: * - * WHAT IS NOT GUARDED, AND WHY: raw `node:net`/`node:tls` sockets, which are how the Postgres - * driver reaches the test database. That host is deployment configuration (`TEST_DATABASE_URL`) and - * is not necessarily loopback, so a guard there would refuse the database rather than a vendor. No - * vendor SDK in this repository speaks a non-HTTP protocol: both brokered vendors, every model - * provider and every OAuth exchange go through `fetch`, and `node:http`/`node:https` are guarded - * alongside it so that a dependency which reaches for the older transport is caught too. + * - `Bun.fetch` is a read-only, non-configurable property and cannot be replaced, and the built-in + * `undici` captured the native fetch before this file ran. Both honour the proxy environment, so + * the process's HTTP(S) proxy is pointed at a refusing listener on loopback (`NO_PROXY` keeps the + * suite's own loopback servers direct). Every native fetch to a far host is handed to that + * listener, which records it and answers 403 without ever opening a socket onward. + * - `globalThis.fetch` is still replaced too, so the common path rejects with the explanation + * below instead of resolving a 403. + * - `node:http`/`node:https` do NOT read the proxy environment, so `request`, `get` and + * `ClientRequest` are replaced on both, and `syncBuiltinESMExports()` pushes the replacements into + * the named-import bindings, which Bun otherwise snapshots when the built-in first loads. + * - `node:net`/`node:tls` are guarded on `net.Socket.prototype.connect`, which `net.connect`, + * `net.createConnection` and `tls.connect` all reach; `Bun.connect`, `node:http2`'s `connect`, + * `WebSocket` (global, `ws`, `undici`, `node:http`) and UDP (`dgram`, `Bun.udpSocket`) likewise. + * + * Raw sockets are allowed to one more place than loopback: the hosts of `TEST_DATABASE_URL` and + * `DATABASE_URL`, because that is how the Postgres driver reaches the test database and that host is + * deployment configuration, not necessarily loopback. + * + * WHAT IS NOT GUARDED, AND WHY — none of these is how any code in this repository talks to a vendor: + * `Bun.SQL` (native; it IS the database path, and its connection never surfaces to JavaScript); + * `Bun.S3Client`/`Bun.redis` (native clients this repository does not use); a `fetch` given an + * explicit `proxy` or `unix` option, or a test that rewrites the proxy variables itself; a child + * process (`Bun.spawn`, `child_process` running `curl`); a `Worker`, whose global scope does not + * run this preload; and DNS lookups, which resolve a name but connect to nothing. + * `server/tests/test-network-guard.test.ts` plants a call through every guarded entry point. * * HOW A SUITE THAT IS MEANT TO DIAL OPTS OUT: any `OPENBOT_LIVE_*` variable with a non-empty value. * The three live suites already set one — `OPENBOT_LIVE_COMPOSIO`, `OPENBOT_LIVE_ARCADE` and @@ -34,8 +56,12 @@ * otherwise. */ +import dgram from "node:dgram"; import http from "node:http"; +import http2 from "node:http2"; import https from "node:https"; +import { syncBuiltinESMExports } from "node:module"; +import net from "node:net"; const liveRun = Object.entries(process.env).some( ([name, value]) => name.startsWith("OPENBOT_LIVE_") && (value ?? "").trim(), @@ -51,7 +77,26 @@ function isLoopback(host: string): boolean { bare === "::" || bare === "" || bare.endsWith(".localhost") || - /^127\./.test(bare) + /^127\./.test(bare) || + /^::ffff:127\./.test(bare) + ); +} + +/** The configured database hosts, read when asked, since a test may point either variable elsewhere. */ +function isDatabaseHost(host: string): boolean { + const bare = host.replace(/^\[|\]$/g, "").toLowerCase(); + return [process.env.TEST_DATABASE_URL, process.env.DATABASE_URL].some( + (configured) => { + if (!configured) return false; + try { + return ( + new URL(configured).hostname.replace(/^\[|\]$/g, "").toLowerCase() === + bare + ); + } catch { + return false; + } + }, ); } @@ -60,9 +105,9 @@ function isLoopback(host: string): boolean { * * A refusal does not always reach the caller as itself: `@composio/client` retries a rejected * transport and then reports "Connection error.", which is the same sentence a real outage would - * produce. So the fact that the guard — rather than the network — stopped a call is recorded here - * at the point of refusal, where it is unambiguous. `server/tests/test-network-guard.test.ts` reads - * it; nothing in `server/src` may. + * produce, and a native `Bun.fetch` sees the refusing proxy's 403. So the fact that the guard — + * rather than the network — stopped a call is recorded here at the point of refusal, where it is + * unambiguous. `server/tests/test-network-guard.test.ts` reads it; nothing in `server/src` may. */ declare global { var openbotTestNetworkRefusals: string[] | undefined; @@ -70,19 +115,97 @@ declare global { globalThis.openbotTestNetworkRefusals = []; +const explanation = + "Tests do not use the network: stub the transport this call goes through (and stub it BEFORE " + + "constructing any client, which binds `fetch` once), or, for a deliberate vendor suite, set an " + + "OPENBOT_LIVE_* variable as `bun run test:live-*` does. Guard: server/scripts/test-network-guard.ts."; + function refuse(target: string): Error { globalThis.openbotTestNetworkRefusals?.push(target); - return new Error( - `A test tried to reach ${target}. Tests do not use the network: stub the transport this call ` + - "goes through (and stub it BEFORE constructing any client, which binds `fetch` once), or, " + - "for a deliberate vendor suite, set an OPENBOT_LIVE_* variable as `bun run test:live-*` " + - "does. Guard: server/scripts/test-network-guard.ts.", - ); + return new Error(`A test tried to reach ${target}. ${explanation}`); +} + +/** The host a Node `request`/`get`/`ClientRequest` call is aimed at, from any of its overloads. */ +function nodeRequestHost(args: unknown[]): string { + let host = ""; + const [first, second] = args; + if (typeof first === "string") host = new URL(first).hostname; + else if (first instanceof URL) host = first.hostname; + for (const options of [first, second]) { + if (options && typeof options === "object" && !(options instanceof URL)) { + const { hostname, host: hostWithPort } = options as { + hostname?: string | null; + host?: string | null; + }; + const named = hostname ?? hostWithPort; + if (named) host = String(named).replace(/:\d+$/, ""); + } + } + return host; +} + +/** The host a `net.Socket#connect` is aimed at; `null` for a Unix socket path, which is local. */ +function socketConnectHost(args: unknown[]): string | null { + const [first, second] = args; + if (Array.isArray(first)) return socketConnectHost(first); + if (first && typeof first === "object") { + const options = first as { path?: string; host?: string }; + if (options.path) return null; + return options.host ?? "localhost"; + } + if (typeof first === "string" && !/^\d+$/.test(first)) return null; + return typeof second === "string" ? second : "localhost"; +} + +function guardSocketHost(host: string | null, label: string): void { + if (host === null || isLoopback(host) || isDatabaseHost(host)) return; + throw refuse(`${label} ${host}`); +} + +type Callable = (...args: unknown[]) => unknown; +type Constructable = new (...args: unknown[]) => object; + +function replace(owner: object, key: string, value: unknown): void { + (owner as Record)[key] = value; } if (!liveRun) { - const passThrough = globalThis.fetch; + /* + * The refusing proxy, for every native fetch this file cannot wrap. `Bun.listen` binds + * synchronously, so the port is known before the variables naming it are set, and the listener + * is unreferenced so it never holds a finished run open. + */ + const refusingProxy = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket, chunk) { + const [method = "", target = ""] = chunk + .toString("latin1") + .split("\r\n", 1)[0] + .split(" "); + refuse( + method === "CONNECT" + ? `https://${target.replace(/:443$/, "")}` + : target.replace(/\?.*$/, ""), + ); + const body = `Refused by the test network guard. ${explanation}`; + socket.end( + "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n" + + "X-Openbot-Test-Network-Guard: refused\r\nContent-Type: text/plain\r\n" + + `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`, + ); + }, + }, + }); + refusingProxy.unref(); + const proxy = `http://127.0.0.1:${refusingProxy.port}`; + const direct = "localhost,127.0.0.1,::1,[::1],0.0.0.0,.localhost"; + for (const name of ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]) + process.env[name] = proxy; + for (const name of ["NO_PROXY", "no_proxy"]) process.env[name] = direct; + const passThrough = globalThis.fetch; globalThis.fetch = ((input: unknown, init?: RequestInit) => { const raw = typeof input === "string" @@ -96,7 +219,7 @@ if (!liveRun) { } catch { // A relative or unparseable target reaches no host; the runtime's own error is the right one. } - if (url && (url.protocol === "http:" || url.protocol === "https:")) { + if (url && /^(https?|wss?):$/.test(url.protocol)) { if (!isLoopback(url.hostname)) { return Promise.reject(refuse(url.origin + url.pathname)); } @@ -105,30 +228,144 @@ if (!liveRun) { }) as typeof globalThis.fetch; /* - * `request` is forwarded through an `unknown[]` signature rather than one of its four overloads: - * nothing here reads an argument it did not already read off the first one, and restating the - * overloads would be a declaration to maintain against Node's. + * `request`, `get` and `ClientRequest` are forwarded through an `unknown[]` signature rather than + * one of their overloads: nothing here reads an argument it did not already read off the first + * two, and restating the overloads would be a declaration to maintain against Node's. `get` is + * replaced on its own because the built-in calls its internal `request`, not this property. */ - type NodeRequest = (...args: unknown[]) => unknown; for (const module of [http, https] as const) { - const passThroughRequest = module.request as unknown as NodeRequest; - (module as unknown as { request: NodeRequest }).request = ( - ...args: unknown[] - ) => { - const first = args[0]; - const target = - typeof first === "string" - ? new URL(first).hostname - : first instanceof URL - ? first.hostname - : ((first as { hostname?: string; host?: string } | null) - ?.hostname ?? - (first as { host?: string } | null)?.host ?? - ""); - if (!isLoopback(String(target).replace(/:\d+$/, ""))) { - throw refuse(String(target)); + for (const key of ["request", "get"] as const) { + const original = module[key] as unknown as Callable; + replace(module, key, (...args: unknown[]) => { + const host = nodeRequestHost(args); + if (!isLoopback(host)) throw refuse(host); + return original(...args); + }); + } + } + const OriginalClientRequest = http.ClientRequest as unknown as Constructable; + replace( + http, + "ClientRequest", + class GuardedClientRequest extends OriginalClientRequest { + constructor(...args: unknown[]) { + const host = nodeRequestHost(args); + if (!isLoopback(host)) throw refuse(host); + super(...args); } - return passThroughRequest(...args); - }; + }, + ); + + const originalSocketConnect = net.Socket.prototype.connect as Callable; + replace( + net.Socket.prototype, + "connect", + function (this: net.Socket, ...args: unknown[]) { + guardSocketHost(socketConnectHost(args), "socket"); + return originalSocketConnect.apply(this, args); + }, + ); + + const originalBunConnect = Bun.connect as unknown as Callable; + // `Bun.connect` answers with a promise, so a refusal is a rejection rather than a throw. + replace(Bun, "connect", (options: { hostname?: string; unix?: string }) => { + try { + guardSocketHost( + options?.unix ? null : (options?.hostname ?? ""), + "socket", + ); + } catch (refusal) { + return Promise.reject(refusal); + } + return originalBunConnect(options); + }); + + const originalHttp2Connect = http2.connect as unknown as Callable; + replace(http2, "connect", (authority: unknown, ...rest: unknown[]) => { + const host = + authority instanceof URL + ? authority.hostname + : typeof authority === "string" + ? new URL(authority).hostname + : ((authority as { hostname?: string; host?: string } | null) + ?.hostname ?? + (authority as { host?: string } | null)?.host ?? + ""); + if (!isLoopback(host)) throw refuse(`http2 ${host}`); + return originalHttp2Connect(authority, ...rest); + }); + + const OriginalWebSocket = globalThis.WebSocket as unknown as Constructable; + class GuardedWebSocket extends OriginalWebSocket { + constructor(...args: unknown[]) { + const url = new URL(String(args[0])); + if (!isLoopback(url.hostname)) throw refuse(url.origin + url.pathname); + super(...args); + } } + replace(globalThis, "WebSocket", GuardedWebSocket); + replace(http, "WebSocket", GuardedWebSocket); + for (const builtin of ["ws", "undici"]) { + const module = require(builtin) as Record; + if (module.WebSocket) replace(module, "WebSocket", GuardedWebSocket); + } + + const originalDgramSend = dgram.Socket.prototype.send as Callable; + replace( + dgram.Socket.prototype, + "send", + function (this: dgram.Socket, ...args: unknown[]) { + // send(msg, port, address?, cb?) or send(msg, offset, length, port, address?, cb?) + const address = + typeof args[2] === "number" && typeof args[3] === "number" + ? args[4] + : args[2]; + if (typeof address === "string") guardSocketHost(address, "udp"); + return originalDgramSend.apply(this, args); + }, + ); + const originalDgramConnect = dgram.Socket.prototype.connect as Callable; + replace( + dgram.Socket.prototype, + "connect", + function (this: dgram.Socket, ...args: unknown[]) { + guardSocketHost( + typeof args[1] === "string" ? args[1] : "localhost", + "udp", + ); + return originalDgramConnect.apply(this, args); + }, + ); + + const originalUdpSocket = Bun.udpSocket as unknown as Callable; + replace( + Bun, + "udpSocket", + async (options: { connect?: { hostname?: string } }) => { + if (options?.connect) + guardSocketHost(options.connect.hostname ?? "", "udp"); + const socket = (await originalUdpSocket(options)) as { + send: Callable; + sendMany: Callable; + }; + const send = socket.send.bind(socket); + socket.send = (...args: unknown[]) => { + if (typeof args[2] === "string") guardSocketHost(args[2], "udp"); + return send(...args); + }; + const sendMany = socket.sendMany.bind(socket); + socket.sendMany = (packets: unknown) => { + if (!options?.connect && Array.isArray(packets)) { + for (let i = 2; i < packets.length; i += 3) + guardSocketHost(String(packets[i]), "udp"); + } + return sendMany(packets); + }; + return socket; + }, + ); + + // Bun snapshots a built-in's named exports when it first loads; this re-reads every replacement + // above into them, so `import { get } from "node:https"` in a test file is the guarded one. + syncBuiltinESMExports(); } diff --git a/server/tests/test-network-guard.test.ts b/server/tests/test-network-guard.test.ts index 1cd97de5c..29a33ed37 100644 --- a/server/tests/test-network-guard.test.ts +++ b/server/tests/test-network-guard.test.ts @@ -1,74 +1,300 @@ import { describe, expect, test } from "bun:test"; +import dgram from "node:dgram"; +import http from "node:http"; +import http2 from "node:http2"; +import https, { get as namedHttpsGet } from "node:https"; +import net from "node:net"; +import tls from "node:tls"; import { createComposioClient } from "../src/plugins/composio-adapter"; /** - * The guard that makes a live vendor call impossible, asserted rather than assumed. + * The guard that makes a live vendor call impossible, asserted rather than assumed — ONE ENTRY + * POINT AT A TIME. * - * `server/scripts/test-network-guard.ts` runs from the preload, before any test file is imported, - * and refuses every HTTP request to a host that is not loopback. It exists because stubbing per - * file does not hold: a vendor client binds its transport in its constructor, so a stub installed - * one line later governs nothing, and the file still reads as if it stubs. That is exactly how - * `composio-broker-seam.test.ts` reached `backend.composio.dev` on every run. + * `server/scripts/test-network-guard.ts` runs from the preload, before any test file is imported. + * Its first version patched `globalThis.fetch` and `http(s).request` only, and this file asserted + * it through `fetch` only, so it stayed green while `https.get` returned 200 from example.com and + * `Bun.fetch` was not guarded at all. A guard is exactly as wide as the entry points its test + * plants a call through, so every entry point a test file could open a connection with is planted + * below, separately, and each one must end in a refusal the GUARD recorded during THAT call. * - * Three facts are worth pinning. The guard refuses the far side; it does NOT refuse the suite's own - * loopback servers, which half the route tests are; and it is in force for a vendor SDK, which is - * the case it was written for and the one a per-file stub kept missing. + * Why the record rather than the error: a refusal does not always reach the caller as itself (the + * Composio SDK retries and says "Connection error.", and a native `Bun.fetch` sees the refusing + * proxy's 403), and an error alone cannot tell a guarded call from a dialled one that failed. Why + * only the entries recorded during the call: the record is process-wide, so a check against the + * whole of it would be satisfied by whichever earlier test happened to be refused. + * + * example.com is the far side throughout: the assertion is that NOTHING is dialled, so the address + * should be one no product code would ever have reason to reach. */ -describe("the test network guard", () => { - test("refuses a request to a host that is not loopback", async () => { - // example.com rather than a vendor: the assertion is that NOTHING is dialled, so the address - // this case names should be one no product code would ever have reason to reach. - const attempt = await fetch("https://example.com/anything").then( - () => "resolved", - (error: unknown) => (error instanceof Error ? error.message : ""), - ); - expect(attempt).toContain("Tests do not use the network"); - expect(attempt).toContain("example.com"); +const FAR = "example.com"; + +/** Runs one planted call and returns what it came to and what the guard recorded while it ran. */ +async function plant( + call: () => unknown, +): Promise<{ outcome: string; refused: string[] }> { + const record = globalThis.openbotTestNetworkRefusals; + if (!record) throw new Error("the network guard did not install its record"); + const before = record.length; + let outcome: string; + try { + const value = await call(); + outcome = + value instanceof Response + ? `response ${value.status} ${value.headers.get("x-openbot-test-network-guard") ?? "unguarded"}` + : "returned"; + } catch (error) { + outcome = error instanceof Error ? error.message : String(error); + } + return { outcome, refused: record.slice(before) }; +} + +const refusedCall = "Tests do not use the network"; +const refusedByProxy = "response 403 refused"; + +/** Each entry point, the call it plants, the outcome the caller sees and the target recorded. */ +const entryPoints: Array<[string, () => unknown, string, string]> = [ + [ + "globalThis.fetch", + () => fetch(`https://${FAR}/x`), + refusedCall, + `https://${FAR}/x`, + ], + [ + "Bun.fetch", + () => Bun.fetch(`https://${FAR}/x`), + refusedByProxy, + `https://${FAR}`, + ], + [ + "Bun.fetch over http", + () => Bun.fetch(`http://${FAR}/x`), + refusedByProxy, + `http://${FAR}/x`, + ], + [ + "undici fetch", + () => + (require("undici") as { fetch: typeof fetch }).fetch(`https://${FAR}/x`), + refusedByProxy, + `https://${FAR}`, + ], + [ + "undici request", + async () => { + const { statusCode, headers } = await ( + require("undici") as { + request: (url: string) => Promise<{ + statusCode: number; + headers: Record; + }>; + } + ).request(`https://${FAR}/x`); + return new Response(null, { status: statusCode, headers }); + }, + refusedByProxy, + `https://${FAR}`, + ], + ["http.request", () => http.request(`http://${FAR}/x`), refusedCall, FAR], + [ + "https.request", + () => https.request({ hostname: FAR, path: "/x" }), + refusedCall, + FAR, + ], + ["http.get", () => http.get(`http://${FAR}/x`), refusedCall, FAR], + ["https.get", () => https.get(`https://${FAR}/x`), refusedCall, FAR], + [ + "named import of https.get", + () => namedHttpsGet(`https://${FAR}/x`), + refusedCall, + FAR, + ], + [ + "http.ClientRequest", + () => new http.ClientRequest(`http://${FAR}/x`), + refusedCall, + FAR, + ], + ["net.connect", () => net.connect(80, FAR), refusedCall, `socket ${FAR}`], + [ + "net.Socket#connect", + () => new net.Socket().connect({ host: FAR, port: 80 }), + refusedCall, + `socket ${FAR}`, + ], + [ + "tls.connect", + () => tls.connect(443, FAR, { servername: FAR }), + refusedCall, + `socket ${FAR}`, + ], + [ + "Bun.connect", + () => Bun.connect({ hostname: FAR, port: 80, socket: { data() {} } }), + refusedCall, + `socket ${FAR}`, + ], + [ + "http2.connect", + () => http2.connect(`https://${FAR}`), + refusedCall, + `http2 ${FAR}`, + ], + [ + "WebSocket", + () => new WebSocket(`wss://${FAR}/x`), + refusedCall, + `wss://${FAR}/x`, + ], + [ + "ws WebSocket", + () => + new (require("ws") as { WebSocket: typeof WebSocket }).WebSocket( + `wss://${FAR}/x`, + ), + refusedCall, + `wss://${FAR}/x`, + ], + [ + "dgram send", + () => { + const socket = dgram.createSocket("udp4"); + try { + socket.send("x", 9, FAR); + } finally { + socket.close(); + } + }, + refusedCall, + `udp ${FAR}`, + ], + [ + "Bun.udpSocket send", + async () => { + const socket = await Bun.udpSocket({}); + try { + socket.send("x", 9, FAR); + } finally { + socket.close(); + } + }, + refusedCall, + `udp ${FAR}`, + ], +]; + +describe("the test network guard refuses a host that is not loopback, through", () => { + test.each(entryPoints)("%s", async (_name, call, seen, target) => { + const { outcome, refused } = await plant(call); + + expect(outcome).toContain(seen); + expect(refused).toEqual([target]); }); +}); - test("lets the suite talk to a server the suite started", async () => { - const server = Bun.serve({ - port: 0, - fetch: () => new Response("local"), - }); +describe("the test network guard lets the suite reach what the suite owns", () => { + test("a loopback HTTP server, through fetch, Bun.fetch and node:http", async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("local") }); try { - const answer = await fetch(`http://127.0.0.1:${server.port}/`); - expect(await answer.text()).toBe("local"); - expect( - await (await fetch(`http://localhost:${server.port}/`)).text(), - ).toBe("local"); + const { outcome, refused } = await plant(async () => { + const answers = [ + await (await fetch(`http://127.0.0.1:${server.port}/`)).text(), + await (await Bun.fetch(`http://localhost:${server.port}/`)).text(), + await new Promise((resolve, reject) => { + http + .get(`http://127.0.0.1:${server.port}/`, (response) => { + let body = ""; + response.on("data", (chunk) => (body += chunk)); + response.on("end", () => resolve(body)); + }) + .on("error", reject); + }), + ]; + return answers.join(","); + }); + + expect(outcome).toBe("returned"); + expect(refused).toEqual([]); } finally { await server.stop(true); } }); + test("a loopback socket, through net.connect", async () => { + const listener = net.createServer((socket) => socket.end("local")); + await new Promise((resolve) => + listener.listen(0, "127.0.0.1", resolve), + ); + const { port } = listener.address() as net.AddressInfo; + try { + const { outcome, refused } = await plant( + () => + new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1"); + let body = ""; + socket.on("data", (chunk) => (body += chunk)); + socket.on("end", () => resolve(body)); + socket.on("error", reject); + }), + ); + + expect(outcome).toBe("returned"); + expect(refused).toEqual([]); + } finally { + listener.close(); + } + }); + /** - * The case the guard was written for, in the shape that defeated the per-file stub: the vendor - * client is constructed with no stub in sight, and its own bound transport is the guard. + * The database is the one far host a raw socket may reach, because it is deployment + * configuration rather than a vendor. `.invalid` never resolves, so the connection fails in DNS + * — and the point is that it got as far as DNS instead of being refused. */ - test("is in force for a vendor SDK that binds its own transport", async () => { - const { broker } = createComposioClient("never-dialled"); - - const attempt = await broker - .authorize({ - account: { holder: "person", userId: "u", vendorUserId: "u" }, - toolkit: "linear", - returnUrl: "https://openbot.test/settings/connected-accounts/x", - }) - .then( - () => "resolved", - (error: unknown) => (error instanceof Error ? error.message : ""), + test("the configured database host, through a raw socket", async () => { + const configured = process.env.TEST_DATABASE_URL; + process.env.TEST_DATABASE_URL = "postgres://u:p@db.openbot.invalid:5432/x"; + try { + const { outcome, refused } = await plant( + () => + new Promise((_resolve, reject) => { + net.connect(5432, "db.openbot.invalid").on("error", reject); + }), ); - expect(attempt).not.toBe("resolved"); - /* - * ASSERTED ON THE GUARD'S OWN RECORD RATHER THAN THE MESSAGE, because the SDK retries the - * rejected transport and reports "Connection error." — the same sentence a real outage gives, - * so a test that read it could not tell a guarded call from a dialled one that failed. - */ - expect(globalThis.openbotTestNetworkRefusals?.join(" ")).toContain( - "backend.composio.dev", - ); + expect(outcome).not.toContain(refusedCall); + expect(refused).toEqual([]); + } finally { + if (configured === undefined) delete process.env.TEST_DATABASE_URL; + else process.env.TEST_DATABASE_URL = configured; + } }); }); + +/** + * The case the guard was written for, in the shape that defeated the per-file stub: the vendor + * client is constructed with no stub in sight, and its own bound transport is the guard. + */ +test("the test network guard is in force for a vendor SDK that binds its own transport", async () => { + const { broker } = createComposioClient("never-dialled"); + + const { outcome, refused } = await plant(() => + broker.authorize({ + account: { holder: "person", userId: "u", vendorUserId: "u" }, + toolkit: "linear", + returnUrl: "https://openbot.test/settings/connected-accounts/x", + }), + ); + + expect(outcome).not.toBe("returned"); + /* + * ASSERTED ON THE GUARD'S OWN RECORD RATHER THAN THE MESSAGE, because the SDK retries the + * rejected transport and reports "Connection error." — the same sentence a real outage gives, + * so a test that read it could not tell a guarded call from a dialled one that failed. + */ + expect(refused.length).toBeGreaterThan(0); + expect( + refused.every((target) => target.includes("backend.composio.dev")), + ).toBe(true); +}); From 944e52662839fefa0111c19c04e125a7da818384 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 04:29:35 +0200 Subject: [PATCH 190/232] fix(db): carry a NULL created_at from the journal into the reach check C3 taught migrationsOutOfReach to pick the migrator's high-water mark the way drizzle picks it -- NULLs sort first, so any NULL makes the mark 0 -- but the producer in this file still mapped rows with `Number(row.created_at ?? 0)`. The NULL was destroyed before the guard could ever see it, so C3's fix could not fire. C3 owned migration-reach.ts and C4/C6 owned this file, so neither side could close the seam alone; C3 reported the exact expression and held its covering test back rather than committing a test that would be red at merge. This is that seam, plus C3's held integration case, which is red under either half alone and green only with both. Watched failing on the merged tree: restoring `Number(row.created_at ?? 0)` fails the new case with Postgres 42P07 (duplicate_table) from heap_create_with_catalog -- the migrator re-running from the start over a table that is already there, which is the original C3 defect reproduced end to end. Restored: 37 pass / 0 fail across migration-reach.integration, migration-reach and migration-journal. Also merged by hand in this file, since git could not: C4's journal parameter and dynamic journal queries with C6's advisory-lock transaction (appliedMigrations now takes TransactionSql AND the journal; migrate() receives the configured journal from inside client.begin). The C6 comment naming drizzle.__drizzle_migrations literally was generalised, because after C4 the table is whatever the journal says. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/db/apply-migrations.ts | 8 ++++- .../tests/migration-reach.integration.test.ts | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/server/src/db/apply-migrations.ts b/server/src/db/apply-migrations.ts index b91062760..0bcab621e 100644 --- a/server/src/db/apply-migrations.ts +++ b/server/src/db/apply-migrations.ts @@ -59,7 +59,13 @@ async function appliedMigrations( `; return rows.map((row) => ({ hash: row.hash, - createdAt: Number(row.created_at ?? 0), + /* + * A NULL `created_at` is CARRIED, never coerced. `Number(null)` is 0, and 0 is a legitimate + * stamp — so coercing here would hand `migratorHighWaterMark` a row indistinguishable from an + * early migration and the guard would pass on exactly the database it exists to refuse. The + * column is a bare `bigint` drizzle leaves nullable; see `./migration-reach`. + */ + createdAt: row.created_at === null ? null : Number(row.created_at), })); } diff --git a/server/tests/migration-reach.integration.test.ts b/server/tests/migration-reach.integration.test.ts index 21c87a633..1c5ce547d 100644 --- a/server/tests/migration-reach.integration.test.ts +++ b/server/tests/migration-reach.integration.test.ts @@ -457,3 +457,37 @@ describe("a migrator racing another migrator with a different journal", () => { expect(pausesExercised).toBeGreaterThanOrEqual(4); }, 120_000); }); + +describe("a database whose migrations table holds a NULL created_at", () => { + test("is refused, rather than migrated from the start again", async () => { + /* + * drizzle reads `order by created_at desc limit 1`; Postgres sorts NULLs first, so the planted + * row is the mark, `Number(null)` is 0, and the unguarded migrator re-runs `0000_first` and dies + * on `relation "first" already exists`. The guard must pick the same row and refuse first. + */ + const url = await throwawayDatabase(); + const client = postgres(url, { max: 1, onnotice: () => {} }); + try { + const folder = await folderOf([first, asShipped]); + await applyMigrations(client, folder); + await client` + insert into drizzle.__drizzle_migrations (hash, created_at) + values ('hand-written', null) + `; + + const refusal = await applyMigrations(client, folder).then( + () => undefined, + (error: unknown) => error, + ); + expect(refusal).toBeInstanceOf(MigrationsOutOfReachError); + expect((refusal as Error).message).toContain( + "1 applied row has a NULL created_at", + ); + expect((refusal as Error).message).toContain( + "0000_first (stamped 1000000)", + ); + } finally { + await client.end({ timeout: 5 }); + } + }); +}); From 398df1bba2a10b60e040f9ca29a51f0873bbf164 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 06:27:30 +0200 Subject: [PATCH 191/232] fix(plugins): route removeServer's sibling cascade through clearEndedSiblings removeServer kept its own sibling loop, spelling rules 2 (revoke answered true) and 3 (no local row, no trail row) but not rule 1: a KEY sibling's brokered_connections row was deleted on the strength of an OAuth revoke that never ended it, leaving a live account at the vendor with no local row to reach it, and the trail claimed it ended. The loop now calls clearEndedSiblings, the helper disconnectBrokered and clearWithdrawnAccount already use, so the three rules have one spelling. The asked app keeps its own treatment (every holder's row deleted in one statement keyed on the ROW's provider) and is passed as endedWith, which the helper never clears. revoked is the vendor's raw per-account answer, as disconnectBrokered passes its own. No signature changed. clearEndedSiblings call sites, all in store.ts: disconnectBrokered, clearWithdrawnAccount, removeServer (new). Test: arcade-sibling-collateral "a KEY sibling's row SURVIVES the removal" - red at 944e5266, green with this change. assertion-shape ALLOW line numbers for arcade-sibling-collateral moved 392->417, 396->421. Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/store.ts | 76 +++++++++---------- .../tests/arcade-sibling-collateral.test.ts | 29 ++++++- server/tests/assertion-shape.test.ts | 4 +- 3 files changed, 64 insertions(+), 45 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 91156ae14..eb6c83451 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -3181,9 +3181,11 @@ export function createPluginStore(options: PluginStoreOptions) { * by somebody else — {@link resolveSiblings} reads the identity, {@link accountRow} the holder — * is exactly that case, and it used to get a row saying it had been disconnected. * - * ALL THREE ARE {@link removeServer}'S, which is the oldest spelling of this cascade and the one - * both callers cite; it keeps 2 and 3 in its own sibling loop. Its loop is NOT routed through - * here yet and so is still missing rule 1 — see the report for round 2's finding A5. + * THE ONLY SPELLING OF THESE RULES. Three acts cascade a revoke onto its siblings — + * {@link disconnectBrokered}, {@link clearWithdrawnAccount} and {@link removeServer} — and all + * three call this. {@link removeServer}'s loop was the oldest spelling and kept only 2 and 3 for + * itself, deleting a KEY sibling's row on a removal; routing it here closed that (round 2, A5a). + * A fourth cascade belongs here too, not beside it. * * `vendorRevocationRequested` IS TRUE ON EVERY ROW THIS WRITES AND IS NOT A GUESS: rule 2 says a * revoke answered true, and that ask ended the siblings with the app it named. `false` means "no @@ -3209,8 +3211,8 @@ export function createPluginStore(options: PluginStoreOptions) { by: string; /** * The trail's one word for why, from the closed set the writers of this event use. Wider than - * either caller's own `reason` so that {@link removeServer}'s `mcp_server_removed` needs no - * change here when its loop joins them. + * any one caller's own `reason`, so {@link removeServer} passes `mcp_server_removed` through + * the same parameter the other two pass theirs. */ reason: BrokeredDisconnectReason; }): Promise { @@ -4484,47 +4486,38 @@ export function createPluginStore(options: PluginStoreOptions) { } /* - * THE SIBLINGS THE VENDOR ENDED WITH THIS APP, each filed as `disconnectBrokered` files one: - * under its own app, naming the app whose removal ended it, with the count of apps that could - * not be asked about. Only for an account whose revoke ANSWERED TRUE. `false` means nothing - * was found to withdraw or the auth provider could not be resolved, and the vendor was not - * shown to have ended anything, so a sibling's row is left standing rather than claimed - * gone. It sits after the revokes above for the same reason the asked app's rows do: an - * exception from any of them left every row standing. + * THE SIBLINGS THE VENDOR ENDED WITH THIS APP, by the three rules written down once in + * {@link clearEndedSiblings} — the same call {@link disconnectBrokered} and + * {@link clearWithdrawnAccount} make. This loop used to spell rules 2 and 3 itself and so + * missed rule 1: a KEY sibling's row was deleted on the strength of an OAuth revoke that + * never ended it, leaving a live account at the vendor with no local row to reach it. * - * AND IT IS REACHED ONLY FOR THE CONFIGURED PROVIDER'S OWN ROWS, which is what keeps the - * `accountRow` delete below — keyed on `brokerProvider` — about the same broker as the - * siblings it deletes: `true` here requires `rowBroker`, and there is no `rowBroker` for a - * row the other broker minted. + * WHAT DIFFERS FROM THOSE CALLERS IS ONLY THE ASKED APP, and it is handled above rather + * than here: a removal ends EVERY holder's row for `toolkit` in one delete keyed on the + * ROW's provider, where a disconnect ends one account's. So `endedWith` is `toolkit`, whose + * rows are already gone, and the helper never clears it. `revoked` is the vendor's raw + * answer per account, as `disconnectBrokered` passes its own `ended`. + * + * It sits after the revokes above for the same reason the asked app's rows do: an exception + * from any of them left every row standing. AND IT CAN CLEAR ONLY THE CONFIGURED PROVIDER'S + * OWN ROWS, which is what keeps the helper's `accountRow` delete — keyed on `brokerProvider` + * — about the same broker as the siblings it deletes: `revoked` true requires `rowBroker`, + * and there is no `rowBroker` for a row the other broker minted. */ for (const [index, account] of accounts.entries()) { const { siblings, unanswered } = endedBesides[index] ?? { siblings: [], unanswered: 0, }; - if (!vendorRevocationRequested[index]) continue; - for (const app of siblings) { - const [deleted] = await database - .delete(brokeredConnections) - .where(accountRow(app, account)) - .returning({ app: brokeredConnections.app }); - if (!deleted) continue; - await recordAuditEvent(auditStore, { - eventType: "mcp.account_disconnected", - targetType: "mcp_server", - targetId: app, - payload: { - actor: by, - server: app, - owner: - account.holder === "person" ? account.userId : "deployment", - reason: "mcp_server_removed", - vendorRevocationRequested: true, - unansweredSiblings: unanswered, - endedWith: toolkit, - }, - }); - } + await clearEndedSiblings({ + siblings, + account, + endedWith: toolkit, + revoked: vendorRevocationRequested[index] ?? false, + unanswered, + by, + reason: "mcp_server_removed", + }); } // Last of all, for the reason above, and skipped entirely on a deployment with no @@ -8486,8 +8479,9 @@ export function createPluginStore(options: PluginStoreOptions) { * an app that had no row here had nothing to explain. * * THAT RULE AND THE TWO BESIDE IT NOW LIVE IN {@link clearEndedSiblings}, which this method - * hands its siblings to and which {@link disconnectBrokered} hands its own to. They were - * spelled twice and the two spellings disagreed about every one of them. + * hands its siblings to and which {@link disconnectBrokered} and {@link removeServer} hand + * their own to. They were spelled three times and the spellings disagreed about every one of + * them. * * `reason` IS ONE WORD AND IT IS THIS ONE. "The consent's context moved on" is not any of the * four {@link disconnectBrokered} takes — nobody disconnected anything, nobody was offboarded, diff --git a/server/tests/arcade-sibling-collateral.test.ts b/server/tests/arcade-sibling-collateral.test.ts index 569d5f54b..c8a6f9149 100644 --- a/server/tests/arcade-sibling-collateral.test.ts +++ b/server/tests/arcade-sibling-collateral.test.ts @@ -91,14 +91,18 @@ function run() { apps.push(names.gmail, names.calendar, names.slack); return names; } -async function server(app: string, mode: "personal" | "shared") { +async function server( + app: string, + mode: "personal" | "shared", + authScheme = "OAUTH2", +) { await database.insert(mcpServers).values({ id: app, title: app, vendor: "Arcade", url: `arcade://${app}`, provenance: "arcade", - authScheme: "OAUTH2", + authScheme, accountMode: mode, }); } @@ -304,6 +308,27 @@ describe("removing an app", () => { }); }); + /* + * RULE 1 OF `clearEndedSiblings`, on the third act that cascades. Calendar shares Gmail's identity + * and auth provider, so `resolveSiblings` names it — but this deployment holds a KEY for it, and + * the one OAuth revoke a removal asks for never ended a key. Its row is the only way left to + * reach that live account, so it stands, and the trail claims nothing about it. + */ + test("a KEY sibling's row SURVIVES the removal, and nothing is written about it", async () => { + const t = run(); + await server(t.gmail, "personal"); + await server(t.calendar, "personal", "API_KEY"); + await hold(t.gmail, t.person); + await hold(t.calendar, t.person); + + await store.removeServer(t.gmail, "admin"); + + expect(await rowsFor([t.gmail, t.calendar])).toEqual([t.calendar]); + expect( + (await trailFor([t.gmail, t.calendar])).map((event) => event.app), + ).toEqual([t.gmail]); + }); + test("a partial vendor failure throws and leaves every row standing", async () => { const t = run(); await server(t.gmail, "personal"); diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 7b7df1424..5697b04db 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -653,14 +653,14 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/arcade-sibling-collateral.test.ts", - line: 392, + line: 417, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(row(t.gmail)?.payload.endedWith).toBeUndefined();", }, { file: "server/tests/arcade-sibling-collateral.test.ts", - line: 396, + line: 421, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(row(t.slack)?.payload.endedWith).toBeUndefined();", From edfcd09f9ae9f259cb712125a6fd2bb203e4f327 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 06:27:38 +0200 Subject: [PATCH 192/232] test(arcade): make the configs() stub honour the query it is handed (A6a) The auth-provider configuration stub in arcade-adapter-connections.test.ts recorded its options and ignored them, so the nested `{ query: { limit, offset } }` shape and its paging were unpinned. An adapter calling `list()` with no options, with a flat `{ limit, offset }` (which RequestOptions drops without a word), with a wrong offset or a wrong page size passed every test. This is the sibling of the `listing()` stub that A6 fixed in wave 1. configs() now serves a page only to a `query` naming a numeric limit and offset, sliced as asked; any other call gets an empty page whose total_count still counts every config, so the adapter's incompleteness cross-check fails loudly. A new test puts the matching instance on page two behind 100 fillers and pins the options verbatim. Planted in authProviderInstances, each red under this test file and green under the base one: - flat { limit, offset }: 23 fail ("Arcade listed 0 of 3 auth providers") - query: undefined: 23 fail - offset: 0: 1 fail ("never ended after 100 pages") - limit: 50: 1 fail ("Arcade listed 50 of 101 auth providers") Call sites of configs(): this file only (beforeEach x2, the case-insensitive test, the resolves-once test, the new paging test). No production code changed. Co-Authored-By: Claude Opus 5.5 --- .../tests/arcade-adapter-connections.test.ts | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index 780083fa3..9a4739777 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -115,14 +115,41 @@ const CONFIGS = [ }, ]; -/** Counts the configuration reads, so a test can pin that a call reads them once. */ +/** + * The configuration listing, PAGED BY THE QUERY IT IS HANDED — the sibling of {@link listing}, and + * scoped for the same reason. + * + * `admin.authProviders.list(options)` takes a bare `RequestOptions`, so its paging lives in a + * NESTED `{ query: { limit, offset } }` and nowhere else: a flat `{ limit, offset }` is not an error + * the SDK reports, it is a request carrying no query at all, answered with whatever page the vendor + * defaults to. A stub that served every config to every call made that shape — and the size and + * position of the page the adapter asks for — unobservable: asking for nothing, asking flat, or + * asking for the wrong offset all passed every test in this file. So a page is served only to a + * `query` naming a numeric `limit` and `offset`, sliced exactly as asked, and any other call gets a + * real empty page whose `total_count` still counts every config — so the adapter's own + * incompleteness cross-check fails loudly rather than answering a silent "not connected". + * + * Returns the options it was asked, so a test can assert the shape by name as well as by effect, + * and pin that a call reads the configurations once. + */ +type ConfigsOptions = { query?: { limit?: unknown; offset?: unknown } }; function configs(items: unknown[] = CONFIGS) { - const asked: unknown[] = []; + const asked: ConfigsOptions[] = []; stub( "list", (options) => { - asked.push(options); - return Promise.resolve({ items, total_count: items.length }); + const o = (options ?? {}) as ConfigsOptions; + asked.push(o); + const { limit, offset } = o.query ?? {}; + if (typeof limit !== "number" || typeof offset !== "number") { + return Promise.resolve({ items: [], total_count: items.length }); + } + return Promise.resolve({ + items: items.slice(offset, offset + limit), + offset, + limit, + total_count: items.length, + }); }, authProvidersProto(), ); @@ -549,6 +576,30 @@ describe("arcade adapter connection lifecycle", () => { expect(asked).toHaveLength(1); expect(read).toHaveLength(1); }); + + /* + * THE CONFIGURATION LISTING'S PAGING, ASSERTED BY NAME AND BY EFFECT. The instance a person is + * held under sits on PAGE TWO, behind a full page of other configurations, so an adapter that + * reads one page, asks flat, or asks for the wrong offset cannot find it; and the options are + * pinned verbatim, because `RequestOptions` drops anything outside `query` without a word. + */ + test("the auth-provider listing is paged in the nested query shape, to the page holding the instance", async () => { + const filler = Array.from({ length: 100 }, (_, n) => ({ + id: `filler-${n}`, + provider_id: "github", + })); + const read = configs([ + ...filler, + { id: "google-openbot", provider_id: "google" }, + ]); + listing([row(1, "google-openbot", { name: "Grace" })]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + expect(read).toEqual([ + { query: { limit: 100, offset: 0 } }, + { query: { limit: 100, offset: 100 } }, + ]); + }); }); /** From ba948cb1d5d9cdbd41c7037af7a22eaa96b620f8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 08:56:11 +0200 Subject: [PATCH 193/232] fix(arcade): make recordAuthorizationMint write-once and never after settlement recordAuthorizationMint matched on (actor_id, created_at) alone. That let a late or duplicated mint rewrite auth_id, even to NULL, on an attempt that had already settled. It could also rebind a live attempt away from the ar_ the person was sent to consent to. The UPDATE now also requires auth_id IS NULL AND completed_at IS NULL. completed_at IS NULL is the rule wave-1 B2 gave closeAuthorizationAttempt. auth_id IS NULL makes the binding write-once. A NULL auth_id is never valid after settlement, because every settling write matches auth_id = $1. Call sites (behaviour: returns false where it used to overwrite): - server/src/plugins/routes.ts authorize route: one mint per fresh claim; return value unused. Unaffected. - server/tests/arcade-attempt-store.integration.test.ts, server/tests/arcade-completion-transaction.integration.test.ts: one mint per fresh claim. Unaffected; both files pass. Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/store.ts | 32 +++++- .../arcade-attempt-store.integration.test.ts | 101 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 91156ae14..f80f7c521 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -6701,7 +6701,29 @@ export function createPluginStore(options: PluginStoreOptions) { /** * Write the `ar_...` the vendor minted onto the attempt, matched on `(actor_id, created_at)` * through {@link attemptNamed}, which is where the precision of that match is argued. - * Answers whether it wrote: false is an attempt that was discarded or replaced meanwhile. + * + * WRITE-ONCE, AND NEVER AFTER SETTLEMENT: the UPDATE also requires `auth_id IS NULL` and + * `completed_at IS NULL`. `auth_id` is what binds the attempt to the authorization the person + * was actually sent to consent to, and both conditions are needed to keep that binding fixed. + * + * `completed_at IS NULL` is {@link closeAuthorizationAttempt}'s rule, for the same reason: a + * settled attempt is a record, not a draft. Without it, a late or duplicated mint rewrote a + * completed attempt's `ar_...`, even to NULL, and the row no longer named the authorization + * the person consented to. + * + * `auth_id IS NULL` is needed because `completed_at IS NULL` alone still lets a duplicated mint + * rebind a LIVE attempt. Once the person has the first id's consent URL, writing a second id + * (or NULL) makes their return leg match nothing. A NULL `auth_id` is never valid after + * settlement: every settling write (`completeAuthorizationAttempt` and the three verdicts + * through `closeAuthorizationAttempt`) matches `auth_id = $1`, which NULL never satisfies. + * So `auth_id IS NULL` alone would already rule out any row the store settled. `completed_at + * IS NULL` is kept anyway, so the guard does not depend on every future writer settling + * through those paths. + * + * The first write is the only one, and it can be NULL: a vendor that answered with no id + * leaves the attempt null, and it expires. Answers whether it wrote. False means the attempt + * was discarded, replaced, already minted or already settled. The route does not act on the + * answer. */ async recordAuthorizationMint(input: { actorId: string; @@ -6713,7 +6735,13 @@ export function createPluginStore(options: PluginStoreOptions) { const written = await database .update(pendingAuthorizations) .set({ authId: input.authId }) - .where(attemptNamed(input.actorId, input.createdAt)) + .where( + and( + attemptNamed(input.actorId, input.createdAt), + isNull(pendingAuthorizations.authId), + isNull(pendingAuthorizations.completedAt), + ), + ) .returning({ actorId: pendingAuthorizations.actorId }); return written.length > 0; }, diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 340f6dd81..d3bb8de59 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -208,6 +208,107 @@ describe("recordAuthorizationMint", () => { expect(wrote).toBe(false); expect((await store.authorizationAttemptFor(a))?.authId).toBeNull(); }); + + /** + * The attempt's `auth_id` as the TABLE holds it. Not `authorizationAttemptFor`, which answers + * null for a settled attempt and so could not see a settled row being rewritten. + */ + const storedAuthId = async (actorId: string) => { + const [row] = await database + .select({ authId: pendingAuthorizations.authId }) + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, actorId)); + if (!row) throw new Error(`no attempt for ${actorId}`); + return row.authId; + }; + + /** Claim and mint one attempt, answering the `created_at` it is named by. */ + const minted = async (actorId: string, authId: string) => { + const got = await claim(actorId); + if (!got.claimed) throw new Error("claim failed"); + const wrote = await store.recordAuthorizationMint({ + actorId, + createdAt: got.attempt.createdAt, + authId, + }); + if (!wrote) throw new Error("first mint failed"); + return got.attempt.createdAt; + }; + + test("a late mint cannot rewrite a COMPLETED attempt's auth_id to null", async () => { + const a = await actor("mint-late-null"); + const createdAt = await minted(a, "ar_consented"); + await database.execute( + sql`update pending_authorizations set completed_at = now(), outcome = 'completed' where actor_id = ${a}`, + ); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt, + authId: null, + }); + expect(await storedAuthId(a)).toBe("ar_consented"); + expect(wrote).toBe(false); + }); + + test("a late mint cannot rebind a FAILED attempt to a different ar_", async () => { + const a = await actor("mint-late-other"); + const createdAt = await minted(a, "ar_consented"); + expect( + await store.failAuthorizationAttempt({ + actorId: a, + authId: "ar_consented", + }), + ).toBe(true); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt, + authId: "ar_other", + }); + expect(await storedAuthId(a)).toBe("ar_consented"); + expect(wrote).toBe(false); + }); + + test("a duplicated mint cannot rebind a LIVE attempt that already holds an ar_", async () => { + const a = await actor("mint-dup"); + const createdAt = await minted(a, "ar_first"); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt, + authId: "ar_second", + }); + expect(await storedAuthId(a)).toBe("ar_first"); + expect(wrote).toBe(false); + }); + + test("a mint cannot bind an attempt settled with no auth_id by a writer outside the store", async () => { + const a = await actor("mint-late-settled-null"); + const got = await claim(a); + if (!got.claimed) throw new Error("claim failed"); + await database.execute( + sql`update pending_authorizations set completed_at = now(), outcome = 'failed' where actor_id = ${a}`, + ); + const wrote = await store.recordAuthorizationMint({ + actorId: a, + createdAt: got.attempt.createdAt, + authId: "ar_late", + }); + expect(await storedAuthId(a)).toBeNull(); + expect(wrote).toBe(false); + }); + + test("a mint still writes onto a live attempt whose vendor answered no id", async () => { + const a = await actor("mint-after-null"); + const got = await claim(a); + if (!got.claimed) throw new Error("claim failed"); + expect( + await store.recordAuthorizationMint({ + actorId: a, + createdAt: got.attempt.createdAt, + authId: null, + }), + ).toBe(true); + expect(await storedAuthId(a)).toBeNull(); + }); }); describe("discardAuthorizationAttempt", () => { From 9a5101a7dd9dfa6ee5287b1d972a71cd3132622d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 10:01:45 +0200 Subject: [PATCH 194/232] test(arcade): exercise cancel under a broker with no authorization flow (B5a) The cancel suite carried a Composio broker (`authorizations: null`) and a `provider` parameter that no case ever passed, plus a `beforeMint` hook no case ever assigned, so the Composio half of DELETE /servers/:id/authorize was untested while the fixtures advertised it. - Five cases under `appAs(..., "composio")`: nothing standing on a Composio app is `{ cancelled: false }` (not the POST's 503); an attempt minted under Arcade is still cancellable after a switch to Composio (attempt-first order); a cancel naming a Composio app leaves an attempt on another app alone; an `arcade://` row is refused 400 under the Composio parser; a non-admin on a Shared Composio app is refused 403. - The Composio broker's `authorize` now throws: POST refuses such a deployment before any mint, so a mint there is a defect, not a case. - `beforeMint` removed: copied from arcade-authorize-route.test.ts, where its throw and hold uses are exercised; nothing here needed it. No production code changed; no call sites affected. No state-guard or audit-row assertion added (owned by B2a / B5b). Co-Authored-By: Claude Opus 5.5 --- server/tests/arcade-authorize-cancel.test.ts | 135 +++++++++++++++++-- 1 file changed, 125 insertions(+), 10 deletions(-) diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 294f04421..0009341b4 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -27,14 +27,14 @@ const personalApp = `arcade-cp-${suite}`; const sharedApp = `arcade-cs-${suite}`; const otherApp = `arcade-co-${suite}`; const movedApp = `arcade-cm-${suite}`; +const composioApp = `composio-cp-${suite}`; +const composioSharedApp = `composio-cs-${suite}`; const SHARED_VENDOR_ID = `openbot-deployment:acme-${suite}:R`; const ACTION = "Gmail.SendEmail"; const SECRET_URL = "https://vendor.example/consent?secret=bearer-capability"; let mints: { vendorUserId: string; action?: string; toolkit: string }[] = []; let nextAuthId: string | null = "ar_minted_1"; -// What the next mint does before answering; a test sets it to throw, or to hold the mint open. -let beforeMint: (() => Promise) | null = null; const arcadeBroker: ConnectedAppBroker = { grantIsLocal: false, @@ -54,7 +54,6 @@ const arcadeBroker: ConnectedAppBroker = { deleteAuthConfig: async () => {}, authorize: async ({ account, toolkit, action }) => { mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); - await beforeMint?.(); return { redirectUrl: SECRET_URL, authorizationId: nextAuthId }; }, isConnected: async () => true, @@ -65,15 +64,17 @@ const arcadeBroker: ConnectedAppBroker = { accountName: async () => "acme-bot", }; +/* + * A broker that runs NO per-action authorization flow — `authorizations: null`, the declared + * capability the cancel route's Composio half is reached under. `POST /authorize` refuses such a + * deployment with a 503 before any mint, so a mint reached under it is a defect, not a case. + */ const composioBroker: ConnectedAppBroker = { ...arcadeBroker, grantIsLocal: true, - // Nothing here asks what auth provider a connection is held under. - authProviderOf: async () => null, authorizations: null, - authorize: async ({ account, toolkit, action }) => { - mints.push({ vendorUserId: account.vendorUserId, action, toolkit }); - return { redirectUrl: SECRET_URL, authorizationId: null }; + authorize: async () => { + throw new Error("nothing here mints under a broker with no flow"); }, }; @@ -191,6 +192,25 @@ beforeAll(async () => { authScheme: "OAUTH2", accountMode: "personal", }, + { + id: composioApp, + title: "Composio Gmail", + vendor: "Composio", + url: `composio://cp${suite}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: "personal", + }, + { + id: composioSharedApp, + title: "Composio Team Gmail", + vendor: "Composio", + url: `composio://cs${suite}`, + provenance: "composio", + authScheme: "OAUTH2", + accountMode: "shared", + sharedVendorUserId: SHARED_VENDOR_ID, + }, ]); await database.insert(mcpTools).values( [personalApp, sharedApp, otherApp, movedApp].map((serverId) => ({ @@ -204,7 +224,14 @@ afterAll(async () => { await database .delete(mcpServers) .where( - inArray(mcpServers.id, [personalApp, sharedApp, otherApp, movedApp]), + inArray(mcpServers.id, [ + personalApp, + sharedApp, + otherApp, + movedApp, + composioApp, + composioSharedApp, + ]), ); if (actors.length) { await database @@ -218,7 +245,6 @@ afterAll(async () => { beforeEach(() => { mints = []; nextAuthId = "ar_minted_1"; - beforeMint = null; }); describe("DELETE /servers/:id/authorize", () => { @@ -402,3 +428,92 @@ describe("DELETE /servers/:id/authorize", () => { } }); }); + +/* + * B5a. THE COMPOSIO HALF: CANCEL UNDER A BROKER WHOSE `authorizations` IS NULL. + * + * The cancel route reads no flow capability at all — it resolves the attempt row first and + * discards it, and only with nothing of this actor's on the named app does it resolve the app + * through `brokeredAccountFor`, under the ACTIVE provider's url parser. So on such a deployment + * the route is still reachable, and these pin what it answers there: a cancel is not the POST's + * "this deployment does not authorize per action" 503, an attempt left standing across a switch + * of provider is still the person's to retract, and the refusals keep their words under the + * Composio parser. + */ +describe("DELETE /servers/:id/authorize on a deployment whose broker runs no authorization flow", () => { + test("with nothing standing, a Composio app answers { cancelled: false }, not the POST's 503", async () => { + const actor = await person("c-none"); + const response = await cancel( + appAs("user", actor, "composio"), + composioApp, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ cancelled: false }); + expect(mints).toEqual([]); + expect(await attemptsFor(actor)).toEqual([]); + }); + + test("an attempt minted before the deployment switched to Composio can still be cancelled", async () => { + const actor = await person("c-switched"); + expect((await authorize(appAs("user", actor), personalApp)).status).toBe( + 200, + ); + const [before] = await attemptsFor(actor); + expect(before?.serverId).toBe(personalApp); + mints = []; + + // Under Composio `arcade://` parses to no app at all, so only the attempt-first order reaches it. + const response = await cancel( + appAs("user", actor, "composio"), + personalApp, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ cancelled: true }); + expect(await attemptsFor(actor)).toEqual([]); + expect(mints).toEqual([]); + }); + + test("a cancel naming a Composio app does not cancel this actor's attempt on another app", async () => { + const actor = await person("c-other"); + expect((await authorize(appAs("user", actor), personalApp)).status).toBe( + 200, + ); + const [before] = await attemptsFor(actor); + + const response = await cancel( + appAs("user", actor, "composio"), + composioApp, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ cancelled: false }); + + const [after] = await attemptsFor(actor); + expect(after?.serverId).toBe(personalApp); + expect(after?.createdAt.getTime()).toBe(before?.createdAt.getTime()); + expect(after?.authId).toBe("ar_minted_1"); + }); + + test("with nothing standing, an `arcade://` row is refused under the Composio parser in so many words", async () => { + const actor = await person("c-refused"); + const response = await cancel( + appAs("user", actor, "composio"), + personalApp, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "That app is not reached through a broker.", + }); + }); + + test("with nothing standing, a non-administrator aimed at a Shared Composio app is refused", async () => { + const actor = await person("c-shared"); + const response = await cancel( + appAs("user", actor, "composio"), + composioSharedApp, + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "An administrator connects shared apps.", + }); + }); +}); From 111af38b3b87806ec17f1c9e5d9f80a615c47e4f Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 10:01:54 +0200 Subject: [PATCH 195/232] fix(app): drop ?action= from an app's page after one read, keeping the grant list's mark `/settings/connected-accounts/$key` read `?action=` live off the search and never cleaned it, so "You just authorized X" replayed on every reload and back-navigation - the omission wave 1's B8 closed for index.tsx's three report parameters. Its only sender is the per-action verifier's redirect (server/src/plugins/routes.ts, `landing`); no in-app Link sets it. It is not a pure one-shot: the same value marks the matching GrantList row with `data-landed`, and the list draws only once its own grants read answers, which can be after the strip has landed. So the value is read ONCE into an `arrival` snapshot (keyed on the app) and BOTH the notice and the GrantList prop draw from it; one navigate then drops `action`. The mark is the same news as the notice, so both outlive the URL for this visit and both are gone on a reload. Call sites of what changed, all inside RouteComponent in $key.tsx; no exported symbol changed: - `landedAction`, now from the snapshot, read by the Shared branch's notice and GrantList and the brokered branch's notice and GrantList (4 reads) - new strip effect (one navigate) Red-green: app/tests/connected-account-landing-param.test.tsx holds the grants read until the URL is stripped. Against base, 2/3 fail with the search still `{"action": "Gmail.ListEmails"}`. Plant A (strip, but read live everywhere): `Unable to find role="status"`. Plant B (notice from snapshot, GrantList fed the live value): `expect(received).toBe(expected) Expected: true Received: false` on `data-landed`. Fix restored: 3 pass. Co-Authored-By: Claude Opus 5.5 --- .../settings/connected-accounts/$key.tsx | 38 +++- .../connected-account-landing-param.test.tsx | 215 ++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 app/tests/connected-account-landing-param.test.tsx diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx index 390b49d91..2d7c734cf 100644 --- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx @@ -1,7 +1,7 @@ import { IconArrowUpRight, IconChevronDown } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, useParams, useSearch } from "@tanstack/react-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { PageEmpty, PageRows, @@ -122,6 +122,39 @@ function RouteComponent() { const { action } = useSearch({ from: "/_authed/settings/connected-accounts/$key", }); + /* + * `?action=` IS READ ONCE HERE AND DROPPED FROM THE URL BELOW, BUT IT FEEDS TWO THINGS. + * + * Its only sender is the per-action verifier's redirect (`server/src/plugins/routes.ts`), so it is + * a report of a consent that has just completed — news exactly once. Left in the address bar it is + * read again on every reload and back-navigation, and "You just authorized X" is redrawn about a + * consent that may be hours old. That is the omission `index.tsx` closed for its own three report + * parameters. + * + * It is NOT only the notice, though: the same value marks the matching row of the grant list with + * `data-landed`, and that list draws only once its own read answers — which can be well after the + * strip has landed. So the strip is safe only because BOTH readers draw from this snapshot, never + * from the live search: read live, the notice would vanish with the parameter and the grant list + * would come up with nothing marked. The mark is the same news as the notice, so it is right for + * both to outlive the URL for this visit and for both to be gone on a reload. + * + * KEYED ON THE APP, so a snapshot taken on one app's page is never repeated on another's should the + * router keep this component mounted across a change of `$key`. + */ + const [arrival] = useState(() => ({ + key, + landedAction: landedActionFrom(action), + })); + const navigate = Route.useNavigate(); + /* + * ONE NAVIGATE, the same shape as `index.tsx`'s: `search` replaces the whole object, so a strip is + * written as dropping its keys from what is there and keeping the rest, never as a rebuilt object. + * `action` is the only key this route accepts, so it is the only one dropped. + */ + useEffect(() => { + if (action === undefined) return; + navigate({ search: ({ action: _a, ...rest }) => rest, replace: true }); + }, [action, navigate]); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); const [notice, setNotice] = useState(null); @@ -186,7 +219,8 @@ function RouteComponent() { brokered && connection !== undefined && siblings.data ? disconnectSiblingsNotice(siblings.data) : null; - const landedAction = landedActionFrom(action); + /* From the snapshot above and never from the live search, which is stripped. */ + const landedAction = arrival.key === key ? arrival.landedAction : null; /* * THE GRANT LIST IS DRAWN WHERE THE BROKER AUTHORIZES PER ACTION, read off the declared * capability and never off the vendor's name. The server computes `authorizesPerAction` from the diff --git a/app/tests/connected-account-landing-param.test.tsx b/app/tests/connected-account-landing-param.test.tsx new file mode 100644 index 000000000..453ed6c72 --- /dev/null +++ b/app/tests/connected-account-landing-param.test.tsx @@ -0,0 +1,215 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import type { GrantState } from "@/lib/plugins/grants"; +import type { PluginServer, PluginsPage } from "@/lib/plugins/queries"; +import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key"; + +/** + * `?action=` IS DROPPED FROM THE URL AFTER ITS ONE READ, AND THE GRANT LIST STILL MARKS ITS ROW. + * + * The parameter's only sender is the per-action verifier's redirect, so it reports a consent that + * has just completed. Left in the address bar it is read again on every reload and back-navigation, + * and "You just authorized X" is redrawn about a consent that may be hours old. + * + * BOTH HALVES ARE PINNED, because the value feeds two readers: the landing notice and the grant + * list's `data-landed` mark. A strip that reads the value live would pass a URL-only assertion and + * leave the grant list with nothing marked; a mark that works off the URL would pass a mark-only + * assertion and replay forever. The grants read here is HELD until the URL has been stripped, so + * the list is drawn strictly after the parameter is gone — the order a slow vendor read produces in + * production, and the one in which a live read of `?action=` marks nothing. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + // Let React's scheduler drain before the DOM globals it reads go away. + await new Promise((resolve) => setTimeout(resolve, 50)); + await GlobalRegistrator.unregister(); +}); + +const APP = "gmail"; + +const GMAIL: GrantState[] = [ + { + action: "Gmail.ListEmails", + state: "authorized", + scopes: ["gmail.readonly"], + }, + { action: "Gmail.SendEmail", state: "needs-consent", scopes: ["gmail.send"] }, +]; + +function server(accountMode: "shared" | null): PluginServer { + return { + authScheme: "OAUTH2", + id: APP, + title: "Gmail", + vendor: "Google", + url: "arcade://gmail", + summary: "Mail.", + docsUrl: "", + provenance: "arcade", + hasCredential: false, + toolsRefreshedAt: null, + lastError: null, + addedBy: null, + dynamicClient: false, + accountMode, + sharedVendorConnected: false, + tools: [], + withdrawn: [], + }; +} + +/** Installs the API, with the grants read answering only once `release` is called. */ +function install(accountMode: "shared" | null) { + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const page: PluginsPage = { + catalogue: [], + servers: [server(accountMode)], + skills: [], + botsMayCallBack: true, + redirectUri: null, + composioConfigured: true, + authorizesPerAction: true, + provider: "arcade", + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); + global.fetch = (async (input: RequestInfo | URL) => { + const path = String(input).split("?")[0] ?? ""; + if (path.endsWith("/grants")) { + await held; + return json({ actions: GMAIL }); + } + if (path === "/api/me") + return json({ user: { id: "u", role: "admin", email: "u@x.io" } }); + if (path === "/api/plugins") return json(page); + if (path.startsWith("/api/plugins/connections")) { + return json({ + connections: + accountMode === "shared" + ? [] + : [ + { + serverId: APP, + scope: "", + connectedAt: "2026-09-10T00:00:00.000Z", + verified: true, + verifiedAt: null, + checkable: false, + }, + ], + redirectUri: null, + }); + } + if (path.endsWith("/connection/siblings")) + return json({ siblings: [], unanswered: 0 }); + if (path.endsWith("/connection/confirm")) return json({ connected: true }); + return json({ error: "unexpected" }, 404); + }) as typeof fetch; + return { release: () => release() }; +} + +type TestRouter = { state: { location: { search: unknown } } }; + +function renderScreen(search: string) { + const rootRoute = createRootRoute({ component: Outlet }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const settingsRoute = createRoute({ + path: "/settings", + getParentRoute: () => authedRoute, + component: Outlet, + }); + const indexRoute = createRoute({ + path: "/connected-accounts/", + getParentRoute: () => settingsRoute, + component: () => null, + }); + const wired = ( + ConnectedAccountRoute as unknown as { + update: (options: unknown) => typeof ConnectedAccountRoute; + } + ).update({ + id: "/connected-accounts/$key", + path: "/connected-accounts/$key", + getParentRoute: () => settingsRoute, + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([settingsRoute.addChildren([indexRoute, wired])]), + ]), + history: createMemoryHistory({ + initialEntries: [`/settings/connected-accounts/${APP}${search}`], + }), + }); + const view = render( + + + , + ); + return { view, router: router as unknown as TestRouter }; +} + +function rowOf(view: ReturnType, action: string) { + return view.container.querySelector(`[data-action="${action}"]`); +} + +for (const accountMode of [null, "shared"] as const) { + const which = accountMode === "shared" ? "a Shared app" : "a personal app"; + + test(`on ${which}, ?action= is dropped from the URL and its row is still marked`, async () => { + const api = install(accountMode); + const { view, router } = renderScreen("?action=Gmail.ListEmails"); + + // Half one: the parameter stops replaying — the URL no longer carries it. + await waitFor(() => expect(router.state.location.search).toEqual({})); + + // The notice survives the strip. + const notice = await view.findByRole("status"); + expect(notice.textContent).toBe("You just authorized Gmail.ListEmails."); + + // Half two: the grant list, drawn only now — after the strip — still marks the landed row. + api.release(); + await waitFor(() => expect(rowOf(view, "Gmail.ListEmails")).not.toBeNull()); + expect(rowOf(view, "Gmail.ListEmails")?.hasAttribute("data-landed")).toBe( + true, + ); + expect(rowOf(view, "Gmail.SendEmail")?.hasAttribute("data-landed")).toBe( + false, + ); + }); +} + +test("with no ?action= nothing is marked and nothing is said", async () => { + const api = install(null); + const { view, router } = renderScreen(""); + api.release(); + await waitFor(() => expect(rowOf(view, "Gmail.ListEmails")).not.toBeNull()); + expect(router.state.location.search).toEqual({}); + expect(view.container.querySelector("[data-landed]")).toBeNull(); + expect(view.container.textContent).not.toContain("just authorized"); +}); From d143fba6f80f2383dc424775211aa1c10167c543 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 10:54:02 +0200 Subject: [PATCH 196/232] fix(arcade): refuse an authorization block the provider read cannot parse authProvidersNamedBy skipped an action whose requirements or requirements.authorization was an unreadable shape: it added no provider and raised nothing. So a Gmail listing with one `google` action and one action whose authorization arrived as a string resolved to `google`, and isConnected / accountName / revoke answered (revoke spending DELETEs) from a listing that was never read in full. That is the fail-open direction. It now reads each row through authorizationRequirementOf (C2, 6d349eff) and returns { named, unreadable }. Absence still names no provider: null/absent requirements or authorization, or a block with no provider_id (the live Airtable shape). An unreadable requirements or authorization, or a provider_id that is present but not a non-blank string, is recorded as `unreadable` (field + shape, never the value). The listing is still read to its end, so the malformed, truncated and unending sentences keep priority. Call sites whose behaviour changed: - connectionsFor (isConnected, accountName, revoke): throws a BrokerRefusalError naming the action, field and shape before the none/several checks. Nothing is deleted. - authProviderOf (seam -> store.ts resolveSiblings, routes.ts audit label): answers null, i.e. unanswered. resolveSiblings already ends nothing for null, and routes.ts already folds a throw to null. - authProvidersNamedBy: its return type changed; those two are its only callers. Tests: four new cases in arcade-adapter-connections and arcade-auth-provider were red on base (promise resolved / "google"). Two absence guards were red when absence was planted as unreadable. Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/arcade-adapter.ts | 103 ++++++++++++++++-- .../tests/arcade-adapter-connections.test.ts | 79 ++++++++++++++ server/tests/arcade-auth-provider.test.ts | 38 +++++++ 3 files changed, 209 insertions(+), 11 deletions(-) diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 1811f7605..9fc0957b3 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -841,7 +841,14 @@ async function connectionsFor( vendorUserId: string, toolkit: string, ): Promise { - const named = await authProvidersNamedBy(client, toolkit); + const { named, unreadable } = await authProvidersNamedBy(client, toolkit); + // FIRST, because an unreadable action makes both counts below unreliable: it may name none, the + // one already read, or another. + if (unreadable) { + throw new BrokerRefusalError( + unreadableProviderSentence(toolkit, unreadable), + ); + } if (named.size !== 1) { throw new BrokerRefusalError( named.size === 0 @@ -1180,7 +1187,9 @@ async function grantsFor( * with itself is UNANSWERED rather than silently resolved to whichever action came first. Case is * carried through untouched, so two spellings of one provider count as two and are likewise * unanswered; whether they are the same provider is the caller's comparison, and - * `connectionSiblings` folds case for the one caller that makes it. + * `connectionSiblings` folds case for the one caller that makes it. A listing carrying ANY action + * whose authorization could not be read is unanswered too, however many ids sit beside it — see + * {@link authProvidersNamedBy}. * * `null` IS WHAT UNANSWERED MEANS, AND IT IS NOT AN ANSWER ANY CALLER MAY SPEND. It is not "no * provider", and a toolkit with no provider id anywhere lands on it for the same reason an @@ -1203,24 +1212,77 @@ async function authProviderOf( client: Arcade, toolkit: string, ): Promise { - const named = await authProvidersNamedBy(client, toolkit); + const { named, unreadable } = await authProvidersNamedBy(client, toolkit); + // An unreadable block is UNANSWERED, for the reason several ids are: the one id beside it is not + // the whole of what the listing says. + if (unreadable) return null; return named.size === 1 ? [...named][0] : null; } /** - * The DISTINCT auth provider ids a toolkit's listing names, read to the listing's end. + * What a toolkit's listing says about its auth provider: the DISTINCT ids it names, and the first + * action whose authorization this deployment could not read, if any. + * + * `unreadable` IS A THIRD ANSWER, NOT AN EMPTY CONTRIBUTION. An action whose `requirements` or + * `requirements.authorization` arrives as a shape this deployment cannot read — or whose + * `provider_id` is present and is not a name — might name ANY provider, so the set beside it is + * not the whole of what the listing says. {@link authorizationRequirementOf} draws the same line for + * the catalogue and the grants screen; this is that line applied to `provider_id`. + */ +type AuthProvidersNamed = { + named: Set; + unreadable: { + action: string | undefined; + field: "requirements" | "authorization" | "provider_id"; + shape: string; + } | null; +}; + +/** + * Why a toolkit's provider could not be read off one unreadable action, for a refusal to say. + * Names the FIELD and the SHAPE that arrived there, never the value, for {@link shapeOf}'s reason. + */ +function unreadableProviderSentence( + toolkit: string, + unreadable: NonNullable, +): string { + const where = unreadable.action + ? `${toolkit}'s action ${unreadable.action}` + : `one of ${toolkit}'s actions`; + const field = + unreadable.field === "provider_id" + ? "auth provider id" + : unreadable.field === "authorization" + ? "authorization requirement" + : "requirements"; + return `Arcade sent ${unreadable.shape} where ${where} carries its ${field}, so which auth provider ${toolkit}'s connections are held under could not be read — that action may name a provider the rest of the listing does not. Nothing was read as connected and nothing was withdrawn — a connection may still be live at the vendor. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`; +} + +/** + * The DISTINCT auth provider ids a toolkit's listing names, read to the listing's end — and the + * first action whose authorization could not be read. * * SEPARATED FROM {@link authProviderOf} SO THAT THE VERDICT AND THE EVIDENCE ARE TWO THINGS. The * seam's `authProviderOf` answers a provider or `null`, and `null` is a verdict with the evidence - * thrown away — zero ids and nine ids arrive at its caller as the same word. {@link connectionsFor} - * REFUSES on either, and a refusal has to say which one happened and name what it read, so it reads - * the set and `authProviderOf` folds the set to its verdict. There is still ONE reading of the wire. + * thrown away — zero ids, nine ids and an unreadable block arrive at its caller as the same word. + * {@link connectionsFor} REFUSES on any of them, and a refusal has to say which one happened and + * name what it read, so it reads the evidence and `authProviderOf` folds it to its verdict. There is + * still ONE reading of the wire. + * + * AN UNREADABLE BLOCK IS NO LONGER SKIPPED. It once contributed nothing and raised nothing, so a + * `Gmail` listing of one `google` action beside one whose authorization arrived as a string + * resolved to `google` — a single provider asserted off a listing this deployment did not read + * whole, which is the fail-open direction for callers that spend DELETEs on the answer. ABSENCE is + * still not unreadability: an action with no authorization block, or a block with no `provider_id` + * (the live `Airtable` shape in `fixtures/arcade-catalogue.json`), names no provider and is read as + * naming none, exactly as {@link authorizationRequirementOf} reads it. */ async function authProvidersNamedBy( client: Arcade, toolkit: string, -): Promise> { +): Promise { const named = new Set(); + let unreadable: AuthProvidersNamed["unreadable"] = null; for await (const item of pagedItems({ pageSize: CATALOGUE_PAGE_SIZE, maxPages: CATALOGUE_MAX_PAGES, @@ -1235,11 +1297,30 @@ async function authProvidersNamedBy( unreadableTotal: (shown) => `Arcade reported the action list for ${toolkit} with a total_count of ${shown}, which is not a count, so whether the auth provider its connections are held under could be read in full is not known.`, })) { - const authorization = record(record(item)?.requirements)?.authorization; - const provider = textOf(record(authorization)?.provider_id); + // Read to the END even after an unreadable action, so a truncated or malformed listing still + // throws its own sentence rather than being reported as one unreadable row. + const read = authorizationRequirementOf(item); + if (read.kind === "none") continue; + const action = textOf(record(item)?.qualified_name); + if (read.kind === "unreadable") { + unreadable ??= { action, field: read.field, shape: read.shape }; + continue; + } + const providerId = read.authorization.provider_id; + if (providerId === undefined || providerId === null) continue; + const provider = textOf(providerId); if (provider) named.add(provider); + else + unreadable ??= { + action, + field: "provider_id", + shape: + typeof providerId === "string" + ? "a blank string" + : shapeOf(providerId), + }; } - return named; + return { named, unreadable }; } /** diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index 780083fa3..556b6b0b1 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -535,6 +535,85 @@ describe("arcade adapter connection lifecycle", () => { ); }); + /** + * AN AUTHORIZATION BLOCK THIS DEPLOYMENT CANNOT READ IS NOT AN ACTION THAT NAMES NO PROVIDER. + * + * `authProvidersNamedBy` once skipped it in silence, so a `Gmail` listing of one readable + * `google` action beside one whose authorization arrived unreadable resolved to `google`, and + * all three members answered — `revoke` spending DELETEs — off a listing that was not read whole. + * The unreadable action may name another provider; one id beside it is not an attribution. + */ + const beside = (unreadable: Record) => + stub( + "list", + (query) => { + const toolkit = (query as { toolkit: string }).toolkit; + return Promise.resolve({ + items: [ + { + qualified_name: `${toolkit}.Readable`, + requirements: { + authorization: { id: "x", provider_id: "google" }, + }, + }, + { qualified_name: `${toolkit}.Unreadable`, ...unreadable }, + ], + }); + }, + toolsProto(), + ); + + test.each([ + [ + "an authorization that is a string", + { requirements: { authorization: "oauth2" } }, + /Arcade sent a string where Gmail's action Gmail\.Unreadable carries its authorization requirement/, + ], + [ + "requirements that are a list", + { requirements: ["oauth2"] }, + /Arcade sent a list where Gmail's action Gmail\.Unreadable carries its requirements/, + ], + [ + "a provider_id that is a number", + { requirements: { authorization: { id: "x", provider_id: 42 } } }, + /Arcade sent a number where Gmail's action Gmail\.Unreadable carries its auth provider id/, + ], + ])( + "an action carrying %s REFUSES, rather than resolving off the readable one", + async (_, unreadable, refusal) => { + beside(unreadable); + listing([row(1, "google-openbot", { name: "Grace" })]); + const deleted: unknown[] = []; + stub("delete", (id) => { + deleted.push(id); + return Promise.resolve(); + }); + const { broker } = createArcadeClient("never-dialled"); + const toolkit = "Gmail"; + await expect(broker.isConnected({ account, toolkit })).rejects.toThrow( + refusal, + ); + await expect(broker.accountName({ account, toolkit })).rejects.toThrow( + refusal, + ); + const error = await broker + .revoke({ account, toolkit }) + .then(() => null) + .catch((thrown: unknown) => thrown); + // AUTHORED, so the Disconnect route shows this sentence rather than vendor-outage advice. + expect(brokerSentence(error)).toMatch(refusal); + expect(deleted).toEqual([]); + }, + ); + + test("an action with NO authorization block beside a readable one still resolves — absence is not unreadability", async () => { + beside({ requirements: { authorization: null } }); + listing([row(1, "google-openbot", { name: "Grace" })]); + const { broker } = createArcadeClient("never-dialled"); + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); + }); + test("each call resolves the provider and its configurations once, however many rows", async () => { const asked = providers({ Gmail: "google" }); const read = configs(); diff --git a/server/tests/arcade-auth-provider.test.ts b/server/tests/arcade-auth-provider.test.ts index 5a068703d..8bb6d1aac 100644 --- a/server/tests/arcade-auth-provider.test.ts +++ b/server/tests/arcade-auth-provider.test.ts @@ -103,4 +103,42 @@ describe("Arcade authProviderOf", () => { await createArcadeClient("never-dialled").broker.authProviderOf("Gmail"), ).toBeNull(); }); + + /** + * AN UNREADABLE AUTHORIZATION BESIDE A READABLE ONE IS UNANSWERED, NOT THE READABLE ONE'S ID. + * The seam's caller (`store.ts`'s sibling resolver) counts `null` as unanswered and ends nothing + * for it; answering `google` here would let it end rows on a listing it never read whole. + */ + test("answers null when another action's authorization could not be read", async () => { + stub(toolsProto(), "list", () => + Promise.resolve({ + items: [ + toolUnder("Gmail", 1, "google"), + { + qualified_name: "Gmail.A2", + requirements: { authorization: "oauth2" }, + }, + ], + }), + ); + + expect( + await createArcadeClient("never-dialled").broker.authProviderOf("Gmail"), + ).toBeNull(); + }); + + test("still answers the provider when another action has NO authorization at all", async () => { + stub(toolsProto(), "list", () => + Promise.resolve({ + items: [ + toolUnder("Gmail", 1, "google"), + { qualified_name: "Gmail.A2", requirements: null }, + ], + }), + ); + + expect( + await createArcadeClient("never-dialled").broker.authProviderOf("Gmail"), + ).toBe("google"); + }); }); From 85eacff235669c4072428926719727ec25f010cc Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 11:09:56 +0200 Subject: [PATCH 197/232] fix(composio): report the scopes of the config a connection was minted on grantsFor measured connection state over every auth config (accountsFor with no asked.configs) but took scopes from this deployment's own configs only. A person connected solely via an operator's hand-made config read `authorized` while carrying THIS deployment's scopes -- a grant they never made. Direction: state stays measured over every config; scopes now come from the configs the person's CONNECTED accounts sit on (row.authConfig.id). State is the same read-only question isConnected answers -- the gate that lets the action run names person+toolkit, not account -- so narrowing state to `ours` would answer needs-consent to someone the gate lets through. Downstream reads resolved fields, so state and scopes must describe the same grant. needs-consent still carries our configs' scopes (what a consent from here would be minted against). An account whose config is unnamed or unreadable lends no scopes. The account listing is now read to the end (no `enough`) so a person on two configs gets the union. User-visible: the grants screen shows an operator-config connection as authorized with that config's scopes, not this deployment's. accountsFor call sites (composio-adapter.ts), audited: - grantsFor: changed (every config, all pages, scopes from rows' configs) - isConnected: unchanged, every config (read-only gate) - accountName: unchanged, every config (read-only display name) - revoke: unchanged, configs = ours (acts on what it finds) Signature change: the row type is now AccountRow ({id, data, authConfig}); readers are the four above plus withdrawableAccounts, which reads only id. Test fake in composio-grants-for.test.ts now honours authConfigIds and emits authConfig.id per row. Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/composio-adapter.ts | 84 ++++++++++---- server/tests/composio-grants-for.test.ts | 135 ++++++++++++++++++++++- 2 files changed, 197 insertions(+), 22 deletions(-) diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index f9ed30b47..d8e594eac 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -235,6 +235,31 @@ type VendorAuthConfig = { scopes?: unknown; }; +/** + * One connected account as the listing hands it over, narrowed to the fields this file reads. + * + * `id` and `data` are read by the withdrawal and by {@link ComposioBroker.accountName}. + * `authConfig` is read by {@link ComposioBroker.grantsFor} alone, for the id of the config the + * account was minted against, because that config — and no other — fixes the scopes the account + * holds. `transformConnectedAccountResponse` builds it on every row from the raw + * `auth_config` (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:55-62`), and + * it is `unknown` for the reason every other field here is: the transform is warn-only. + */ +type AccountRow = { id?: unknown; data?: unknown; authConfig?: unknown }; + +/** + * The id of the auth config one account was minted against, or null where Composio did not name it. + * + * NULL RATHER THAN A REFUSAL, BECAUSE ITS ONE READER REPORTS RATHER THAN ACTS — the reasoning of + * {@link scopesOf}, one field along. An account whose config cannot be named is still an account, + * and what is lost is only the description of what it holds. + */ +function configIdOf(row: AccountRow): string | null { + const config = row.authConfig; + if (!config || typeof config !== "object") return null; + return textOf((config as { id?: unknown }).id); +} + /** * The connected-account statuses this file knows how to ask for, as the literals the SDK admits. * @@ -2268,7 +2293,7 @@ export type ComposioVendor = { * same function reads `response.auth_config.id` (`:60`) on its way to building each row, so a * row that is not an object raises inside the vendor's code and never reaches this listing. */ - items: { id?: unknown; data?: unknown }[]; + items: AccountRow[]; /** * The same truncation signal as the auth-config listing above, from the same vendor schema. * @@ -2584,8 +2609,8 @@ export function buildComposioClient( * * AND SO ARE THE OTHER TWO THINGS THE TWO QUESTIONS DO NOT SHARE, for the same reason the * statuses are. `configs` is which authorization configs the answer is about — every one of them - * for the gate, and only this deployment's for the withdrawal, because the withdrawal ACTS on - * what it finds. `enough` is when the rows in hand already settle the question, which is true of + * for the three that only READ (the gate, `grantsFor` and `accountName`), and only this + * deployment's for the withdrawal, because the withdrawal ACTS on what it finds. `enough` is when the rows in hand already settle the question, which is true of * a boolean the moment one row arrives and never true of a set of accounts to end. Both are * written at the call sites below, beside the statuses, so that the whole of the difference * between the two questions is one argument list a reader can compare. @@ -2596,9 +2621,9 @@ export function buildComposioClient( statuses: VendorAccountStatus[], asked: { configs?: string[]; - enough?: (rows: { id?: unknown; data?: unknown }[]) => boolean; + enough?: (rows: AccountRow[]) => boolean; } = {}, - ): Promise<{ id?: unknown; data?: unknown }[]> => { + ): Promise => { /* * EVERY PAGE UNLESS THE CALLER SAYS OTHERWISE, READ HERE RATHER THAN AT EITHER CALLER, so that * the two questions cannot drift on the one thing they do share. A truncated listing is the @@ -3094,25 +3119,46 @@ export function buildComposioClient( * would make one seam member answer two different questions depending on who implemented it, * which is the defect this file already carried at {@link ComposioBroker.accountName}. * - * THE ACCOUNT LISTING GOES FIRST, AND THE ORDER IS THE CHEAP QUESTION BEFORE THE DEAR ONES. It - * is the one call that can settle on a single row — see `accountsFor`'s `enough` — while the - * config listing and the action listing both read every page. + * THE STATE IS MEASURED OVER EVERY CONFIG, AS {@link ComposioBroker.isConnected}'S IS. The two + * are the same read-only question — can this person act through the app — and the call that + * runs an action names the person and the toolkit, not the account, so an account on an + * operator's hand-made config is one they act through. Measuring this one over our configs + * alone would answer `needs-consent` to somebody the gate lets through, and send them to + * consent to an app they already use. Only {@link ComposioBroker.revoke}, which ACTS on what it + * finds, is scoped to ours. + * + * SO THE SCOPES ARE THE CONFIGS THIS PERSON'S ACCOUNTS WERE MINTED AGAINST, WHICH NEED NOT BE + * OURS. A config fixes its scope set when it is created and every account on it holds exactly + * that set, so an `authorized` answer carries the scopes of the configs the counted accounts + * sit on — every one of them, which is why the listing is read to the end rather than stopped at + * the first row. This used to carry THIS DEPLOYMENT'S scopes whatever the account sat on, which + * paired `authorized` with a scope set the person never granted. A `needs-consent` answer + * carries our configs' scopes instead, because those are the configs a consent from here is + * minted against: it describes what they would be asked for. * - * SCOPES COME FROM THIS DEPLOYMENT'S OWN CONFIGS AND FROM NO OTHER. `configsFor` returns the - * operator's hand-made configs beside ours, and a scope set nobody here chose is not what this - * person's connection was minted against — the same distinction {@link madeHere} draws for - * every destructive caller, drawn here for a descriptive one. The names are deduplicated and - * ordered so that two readings of one unchanged deployment are one answer. + * AN ACCOUNT WHOSE CONFIG CANNOT BE NAMED, OR IS NOT AMONG THE CONFIGS THAT COULD BE READ, + * LENDS NO SCOPES — neither its own, which nothing here can read, nor ours, which it does not + * hold. The names are deduplicated and ordered so that two readings of one unchanged deployment + * are one answer. */ async grantsFor({ toolkit, account }): Promise { - const held = await accountsFor(account.vendorUserId, toolkit, CONNECTED, { - enough: (found) => found.length > 0, - }); - const state = held.length > 0 ? "authorized" : "needs-consent"; + const connected = await accountsFor( + account.vendorUserId, + toolkit, + CONNECTED, + ); + const state = connected.length > 0 ? "authorized" : "needs-consent"; - const { ours } = await configsFor(toolkit); + const { held, ours } = await configsFor(toolkit); + const mintedOn = new Set( + connected.map(configIdOf).filter((id) => id !== null), + ); + const granting = + state === "authorized" + ? held.filter((config) => mintedOn.has(config.id)) + : ours; const scopes = [ - ...new Set(ours.flatMap((config) => config.scopes)), + ...new Set(granting.flatMap((config) => config.scopes)), ].sort(); const published = await actions.listActions(toolkit, { diff --git a/server/tests/composio-grants-for.test.ts b/server/tests/composio-grants-for.test.ts index e741782c7..a495b3dca 100644 --- a/server/tests/composio-grants-for.test.ts +++ b/server/tests/composio-grants-for.test.ts @@ -75,10 +75,18 @@ type AccountStatus = * row and be read by the adapter as fields the vendor sent. */ type AccountFixture = { - row: { id?: unknown; data?: unknown }; + row: { id?: unknown; data?: unknown; authConfig?: unknown }; userId: string; status: AccountStatus; toolkit: string; + /** + * The id of the auth config this account was minted against, which the listing filters + * `authConfigIds` on and which the row carries as `authConfig.id` — as `@composio/core` 0.18.1 + * builds every row (`src/utils/transformers/connectedAccounts.ts:55-62`). Written once here so + * the filter and the row cannot disagree; a row that spells its own `authConfig` keeps it, which + * is how a test hands over one Composio described unreadably. + */ + config: string; }; /** An auth config as the vendor holds it, beside the app whose listing returns it. */ @@ -143,9 +151,16 @@ function vendorWith(parts: { query.toolkitSlugs.includes(account.toolkit) && // `some` rather than `includes` only because the query's element type is the // adapter's own private union: the comparison is the same one. - query.statuses.some((status) => status === account.status), + query.statuses.some((status) => status === account.status) && + // Absent means every config, which is what the vendor does with no filter at all. + (query.authConfigIds === undefined || + query.authConfigIds.includes(account.config)), ) - .map((account) => account.row), + .map((account) => + "authConfig" in account.row + ? account.row + : { ...account.row, authConfig: { id: account.config } }, + ), nextCursor: null, }), link: refuse("connectedAccounts.link"), @@ -197,6 +212,7 @@ describe("Composio's grantsFor", () => { userId: ACCOUNT.vendorUserId, status: "ACTIVE", toolkit: "gmail", + config: "ac_1", }, ], configs: [OPERATORS_GMAIL_CONFIG, OUR_GMAIL_CONFIG], @@ -300,6 +316,7 @@ describe("Composio's grantsFor", () => { userId: OTHER_PERSON, status: "ACTIVE", toolkit: "gmail", + config: "ac_1", }, ], configs: [OUR_GMAIL_CONFIG, OPERATORS_GMAIL_CONFIG], @@ -342,6 +359,7 @@ describe("Composio's grantsFor", () => { userId: ACCOUNT.vendorUserId, status, toolkit: "gmail", + config: "ac_1", }, ], configs: [OUR_GMAIL_CONFIG, OPERATORS_GMAIL_CONFIG], @@ -376,6 +394,7 @@ describe("Composio's grantsFor", () => { userId: ACCOUNT.vendorUserId, status: "ACTIVE", toolkit: "slack", + config: "ac_2", }, ], configs: [ @@ -420,6 +439,115 @@ describe("Composio's grantsFor", () => { ]); }); + /** + * THE SCOPES ARE THE CONFIG THE CONNECTION WAS MINTED AGAINST, WHICH NEED NOT BE OURS. + * + * `isConnected` — the gate `./access` asks before running an action — counts an account on any + * config, because the call that runs an action names the person and the toolkit, not the + * account. So a person whose only gmail account sits on an operator's hand-made config CAN act, + * and `authorized` is the truth about them. What they hold is that config's scope set: answering + * `authorized` with THIS deployment's scopes describes a grant they never made. Narrowing the + * state to our configs instead would tell them to consent to an app the gate already lets them + * use — the two read-only seam members disagreeing about one person. + */ + test("answers authorized with the operator's config's scopes where that is the only config this person is connected on", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [ + { + row: { id: "ca_op" }, + userId: ACCOUNT.vendorUserId, + status: "ACTIVE", + toolkit: "gmail", + config: "ac_0", + }, + ], + configs: [OUR_GMAIL_CONFIG, OPERATORS_GMAIL_CONFIG], + actions: [{ row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }], + }), + ); + + expect( + await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), + ).toEqual([ + { + action: "GMAIL_FETCH_EMAILS", + state: "authorized", + scopes: ["gmail.modify"], + }, + ]); + // And the gate agrees, which is the consistency the state is measured for. + expect( + await broker.isConnected({ toolkit: "gmail", account: ACCOUNT }), + ).toBe(true); + }); + + test("answers authorized with every scope this person holds where they are connected on both configs", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [ + { + row: { id: "ca_op" }, + userId: ACCOUNT.vendorUserId, + status: "ACTIVE", + toolkit: "gmail", + config: "ac_0", + }, + { + row: { id: "ca_ours" }, + userId: ACCOUNT.vendorUserId, + status: "ACTIVE", + toolkit: "gmail", + config: "ac_1", + }, + ], + configs: [OUR_GMAIL_CONFIG, OPERATORS_GMAIL_CONFIG], + actions: [{ row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }], + }), + ); + + expect( + await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), + ).toEqual([ + { + action: "GMAIL_FETCH_EMAILS", + state: "authorized", + scopes: ["gmail.modify", "gmail.readonly", "gmail.send"], + }, + ]); + }); + + /** + * A CONFIG NOBODY CAN NAME LENDS NO SCOPES — neither ours nor anybody's. An account whose + * `authConfig` Composio described unreadably is still an ACTIVE account, so the state is + * `authorized`; what it was minted against is not something this deployment can read, and the + * answer says nothing about it rather than borrowing this deployment's scope set. The same + * report-not-refuse posture `scopesOf` takes for a config publishing no scopes at all. + */ + test("lends no config's scopes to an account whose config Composio did not name", async () => { + const { broker } = buildComposioClient( + vendorWith({ + accounts: [ + { + row: { id: "ca_blank", authConfig: { id: 7 } }, + userId: ACCOUNT.vendorUserId, + status: "ACTIVE", + toolkit: "gmail", + config: "ac_1", + }, + ], + configs: [OUR_GMAIL_CONFIG, OPERATORS_GMAIL_CONFIG], + actions: [{ row: { slug: "GMAIL_FETCH_EMAILS" }, toolkit: "gmail" }], + }), + ); + + expect( + await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), + ).toEqual([ + { action: "GMAIL_FETCH_EMAILS", state: "authorized", scopes: [] }, + ]); + }); + /** * A config publishing no scope set is an empty list rather than a refusal, because this method * REPORTS rather than acts: Composio publishes no scopes for a key-scheme config at all, and an @@ -434,6 +562,7 @@ describe("Composio's grantsFor", () => { userId: ACCOUNT.vendorUserId, status: "ACTIVE", toolkit: "firecrawl", + config: "ac_1", }, ], configs: [ From fce3a58190fa0ce867c47b39c43e85ba29e9b14d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 11:10:31 +0200 Subject: [PATCH 198/232] fix(plugins): refuse to discard a settled authorization attempt (B2a) `discardAuthorizationAttempt` deleted on `(actor_id, created_at)` with no state guard. Every caller reads the attempt first and deletes it after, and `(actor_id, created_at)` still names the row once it settles. So a completion committing between the cancel route's read and its DELETE was erased outright: the one row saying the person's consent went through, with the grant live at the vendor, `brokered_connections` written and `mcp.account_connected` on the trail. The route then answered `{ cancelled: true }` for a consent that had succeeded. The DELETE is now conditional on `completed_at IS NULL`, the rule `closeAuthorizationAttempt` (B2) and the completion already hold. The DELETE waits on the completion's row lock and re-evaluates the condition against the committed row, so the guard holds under the race. WHAT THE CANCEL ROUTE ANSWERS INSTEAD. When the discard refuses and the attempt it named COMPLETED, 409 with "That authorization finished before it could be cancelled, so the account is connected. Disconnect it if you did not mean to connect it." Neither boolean is true: `{ cancelled: true }` is a lie, and `{ cancelled: false }` reads as "there was nothing to cancel" when the person asked for nothing to be connected and something was. Any other settled outcome (failed, unreconciled, withdrawn) connected nothing and is the ordinary `{ cancelled: false }`. The route's docblock claimed a completed attempt met the person's intent ("the attempt may have expired, completed or been cancelled already, and the person's intent (no attempt standing) is met either way"). That sentence defended the defect and is rewritten. B5 AND THE WINDOW. B5 moved the attempt read ahead of `brokeredAccountFor`. The gap between the read and the DELETE is still one await, so it is no wider per request. More requests now reach it, though: an attempt whose app stopped resolving, or a demoted administrator's Shared-app attempt, used to be refused before the DELETE and now go through it. The guard is in the store, so it covers the old shape and the new one. CALL SITES. `discardAuthorizationAttempt` still answers `Promise`. `false` now also covers "the row had settled". - routes.ts:2872 (definite mint refusal) and :2951 (mint not recorded) discard an attempt whose `auth_id` is null or was never handed to a browser, so it cannot have completed. No change in behaviour. - routes.ts:3050 (DELETE /servers/:id/authorize) is the only caller whose answer changes, as described above. New method `settledAuthorizationOutcome({ actorId, createdAt })`, named through `attemptNamed`; its one caller is routes.ts:3056. RED-GREEN. New tests run against base 944e5266 store.ts + routes.ts: route race test: received status 200, body { cancelled: true }, outcomes [] (expected 409, the error, ["completed"]) store tests: a completed / failed / unreconciled / withdrawn attempt is gone after the discard (received []) With the fix: 29/29 pass in the two files. Planted violations: dropping only the new `isNull` (route fix kept) turned all 5 red with the same data loss. Disabling only the route's 409 branch turned the route test red with status 200, body { cancelled: false }. Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/routes.ts | 29 ++++++++- server/src/plugins/store.ts | 43 ++++++++++++- .../arcade-attempt-store.integration.test.ts | 39 +++++++++++ server/tests/arcade-authorize-cancel.test.ts | 64 ++++++++++++++++++- 4 files changed, 171 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 34c5013d1..add7df4ab 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2982,8 +2982,18 @@ export function createPluginRoutes( * Cancel THIS actor's authorization attempt, so somebody who closed the consent tab need not wait * out the fifteen-minute TTL. Answers `{ cancelled }`: whether an attempt was removed. * - * CANCELLING WHEN NO ATTEMPT EXISTS IS NOT AN ERROR — the attempt may have expired, completed or - * been cancelled already, and the person's intent (no attempt standing) is met either way. + * CANCELLING WHEN NO ATTEMPT EXISTS IS NOT AN ERROR — the attempt may have expired, failed or + * been cancelled already, and the person's intent (nothing in flight, nothing connected) is met. + * + * A CONSENT THAT COMPLETED UNDER THE CANCEL IS THE ONE CASE WHERE THAT INTENT IS NOT MET, AND IT + * IS ANSWERED 409 RATHER THAN `{ cancelled: true }` OR `{ cancelled: false }`. The attempt is read + * and then discarded, and a completion can commit between the two. `discardAuthorizationAttempt` + * refuses a settled row, so the record of that consent survives; but the account IS connected + * now, and neither boolean says so — `true` would be a lie and `false` reads as "there was nothing + * to cancel". The person asked for nothing to be connected and something was, so they are told + * which, in an error a client cannot mistake for success, and pointed at the one thing that undoes + * it: disconnecting. Any other settled outcome (failed, unreconciled, withdrawn) connected nothing, + * so it is the ordinary `{ cancelled: false }`. * * MATCHED ON `(actor_id, created_at)`, NEVER ON `auth_id`: an attempt whose mint never completed * has no `auth_id` and is exactly the one most worth cancelling, and `created_at` is what stops @@ -3041,6 +3051,21 @@ export function createPluginRoutes( actorId, createdAt: attempt.createdAt, }); + if ( + !cancelled && + (await store.settledAuthorizationOutcome({ + actorId, + createdAt: attempt.createdAt, + })) === "completed" + ) { + return context.json( + { + error: + "That authorization finished before it could be cancelled, so the account is connected. Disconnect it if you did not mean to connect it.", + }, + 409, + ); + } return context.json({ cancelled }); } const resolved = await brokeredAccountFor(context, serverId); diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 91156ae14..952ef0f45 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -6722,6 +6722,20 @@ export function createPluginStore(options: PluginStoreOptions) { * Delete one attempt, matched on `(actor_id, created_at)` SO IT CAN NEVER DELETE A NEWER * ATTEMPT BY THE SAME ACTOR. Matching on `auth_id` would be wrong: an attempt whose mint never * completed has none, and that is exactly the one most worth cancelling. + * + * CONDITIONAL ON `completed_at IS NULL`, THE SAME RULE {@link closeAuthorizationAttempt} AND + * THE COMPLETION HOLD, BECAUSE A SETTLED ATTEMPT IS A RECORD AND NOT A DRAFT. `(actor_id, + * created_at)` does not stop naming a row when the row settles, and every caller of this reads + * the attempt FIRST and deletes it AFTER. Unconditional, a completion committing in that gap + * was erased outright — the one row saying this person's consent went through, with the grant + * live at the vendor, the `brokered_connections` row written and `mcp.account_connected` on + * the trail — and the cancel route then answered `{ cancelled: true }` for a consent that had + * succeeded. The DELETE waits on the completion's row lock and re-evaluates this condition + * against the committed row, so the guard holds under the race and not merely before it. + * + * `false` therefore means one of three things — no row under that key, a row under a different + * `created_at`, or a row that has settled — and all three are "nothing was discarded". A caller + * that must tell the third apart asks {@link settledAuthorizationOutcome}. */ async discardAuthorizationAttempt(input: { actorId: string; @@ -6730,11 +6744,38 @@ export function createPluginStore(options: PluginStoreOptions) { }): Promise { const deleted = await database .delete(pendingAuthorizations) - .where(attemptNamed(input.actorId, input.createdAt)) + .where( + and( + attemptNamed(input.actorId, input.createdAt), + isNull(pendingAuthorizations.completedAt), + ), + ) .returning({ actorId: pendingAuthorizations.actorId }); return deleted.length > 0; }, + /** + * How the attempt named by `(actor_id, created_at)` ended, or null while it has not ended, or + * when no row carries that key. + * + * FOR THE ONE CALLER THAT HAS TO SAY WHY A DISCARD REFUSED: the cancel route, which must not + * answer a person whose consent COMPLETED while they were cancelling it as though nothing had + * happened. Named through {@link attemptNamed} like the discard it explains, so it can only + * report the attempt that discard was aimed at and never a newer one by the same actor. Once + * settled, a row keeps that `created_at` until a new claim takes the key and rewrites it, so a + * reading after the refused DELETE answers for the same attempt the DELETE refused. + */ + async settledAuthorizationOutcome(input: { + actorId: string; + createdAt: Date | string; + }): Promise { + const [row] = await database + .select({ outcome: pendingAuthorizations.outcome }) + .from(pendingAuthorizations) + .where(attemptNamed(input.actorId, input.createdAt)); + return row?.outcome ?? null; + }, + /** * Finish one Arcade authorization: consume the attempt, record the connection and file the * trail, ALL THREE IN ONE TRANSACTION OR NONE OF THEM. diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 340f6dd81..df8025b65 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -256,6 +256,45 @@ describe("discardAuthorizationAttempt", () => { newer.attempt.createdAt.getTime(), ); }); + + /* + * B2a. A SETTLED ATTEMPT IS A RECORD, AND A DISCARD AIMED AT IT BY ITS OWN `created_at` MUST NOT + * ERASE IT. Every caller reads the attempt first and discards it after, so the `created_at` it + * holds still names the row once a completion has committed in between. + */ + for (const outcome of [ + "completed", + "failed", + "unreconciled", + "withdrawn", + ] as const) { + test(`leaves a ${outcome} attempt standing and answers false`, async () => { + const a = await actor(`discard-settled-${outcome}`); + const got = await claim(a); + if (!got.claimed) throw new Error("claim failed"); + await database.execute( + sql`update pending_authorizations set completed_at = now(), outcome = ${outcome} where actor_id = ${a}`, + ); + + const deleted = await store.discardAuthorizationAttempt({ + actorId: a, + createdAt: got.attempt.createdAt, + }); + + const rows = await database + .select() + .from(pendingAuthorizations) + .where(eq(pendingAuthorizations.actorId, a)); + expect(rows.map((row) => row.outcome)).toEqual([outcome]); + expect(deleted).toBe(false); + expect( + await store.settledAuthorizationOutcome({ + actorId: a, + createdAt: got.attempt.createdAt, + }), + ).toBe(outcome); + }); + } }); describe("authorizationAttemptFor", () => { diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 294f04421..ede52fa82 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -13,7 +13,12 @@ import { Hono, type MiddlewareHandler } from "hono"; import { createAuditStore } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; import { createDatabase } from "../src/db/client"; -import { mcpServers, mcpTools, pendingAuthorizations } from "../src/db/schema"; +import { + brokeredConnections, + mcpServers, + mcpTools, + pendingAuthorizations, +} from "../src/db/schema"; import type { BrokerProvider } from "../src/plugins/access"; import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginRoutes } from "../src/plugins/routes"; @@ -322,6 +327,63 @@ describe("DELETE /servers/:id/authorize", () => { expect(after?.outcome).toBeNull(); }); + /* + * B2a. A COMPLETION THAT COMMITS BETWEEN THE CANCEL'S READ AND ITS DELETE. The route reads the + * live attempt, then discards it by `(actor_id, created_at)`; the read is wrapped here so the + * REAL completion runs, against the real row, in exactly that gap. Unguarded, the DELETE erased + * the completed attempt and the route answered `{ cancelled: true }` for a consent that had + * succeeded — the grant live at the vendor and the connection written. + */ + test("a consent that completes under the cancel survives it, and the cancel says so", async () => { + const actor = await person("raced"); + const toolkit = `race-${suite}`; + expect((await authorize(appAs("user", actor), personalApp)).status).toBe( + 200, + ); + const racing = appAs("user", actor, "arcade", (store) => ({ + ...store, + authorizationAttemptFor: async (actorId: string) => { + const live = await store.authorizationAttemptFor(actorId); + if (live?.authId) { + await store.completeAuthorizationAttempt({ + actorId, + authId: live.authId, + toolkit, + account: { + holder: "person", + userId: actorId, + vendorUserId: live.vendorUserId, + }, + action: ACTION, + scopes: [], + authProvider: "google", + }); + } + return live; + }, + })); + try { + const response = await cancel(racing, personalApp); + // One comparison, so a failure shows the answer AND what became of the record side by side. + expect({ + status: response.status, + body: await response.json(), + outcomes: (await attemptsFor(actor)).map((row) => row.outcome), + }).toEqual({ + status: 409, + body: { + error: + "That authorization finished before it could be cancelled, so the account is connected. Disconnect it if you did not mean to connect it.", + }, + outcomes: ["completed"], + }); + } finally { + await database + .delete(brokeredConnections) + .where(eq(brokeredConnections.app, toolkit)); + } + }); + /* * B5. CANCELLING IS AN OPERATION ON THE ATTEMPT, AND THE ATTEMPT OUTLIVES WHAT THE APP LOOKS * LIKE. The route used to resolve the app FIRST and refuse on anything `brokeredAccountFor` From 2844ce2edb36826ba0c316e8dc7cef1f1a82244b Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 12:14:01 +0200 Subject: [PATCH 199/232] fix(arcade): file mcp.authorization_cancelled when a person retracts a pending authorization DELETE /servers/:id/authorize deleted the attempt and left no audit row, on a lifecycle whose declared subject is attributable consent. A successful cancel now files mcp.authorization_cancelled, shaped like its siblings (mcp.account_connected / mcp.account_disconnected): targetType mcp_server, targetId the app slug (the attempt's server row id once the url no longer names an app), who-did-it in payload.actor, and payload {actor, server, serverId, action, holder, minted}. No consent url and no auth_id in the payload. Filed only when discardAuthorizationAttempt answered true, and only from the cancel route: the mint-failure clean-ups that share the deletion are not a person withdrawing consent. discardAuthorizationAttempt itself is unchanged. Call sites: new PluginStore.recordAuthorizationCancelled has one caller, routes.ts DELETE /servers/:id/authorize. New audit event type mcp.authorization_cancelled has one writer (store.ts). Co-Authored-By: Claude Opus 5.5 --- server/src/audit.ts | 13 ++++ server/src/plugins/routes.ts | 11 +++ server/src/plugins/store.ts | 46 ++++++++++++ server/tests/arcade-authorize-cancel.test.ts | 79 +++++++++++++++++++- 4 files changed, 148 insertions(+), 1 deletion(-) diff --git a/server/src/audit.ts b/server/src/audit.ts index 8c2ffab31..6b3705a54 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -234,6 +234,19 @@ export const auditEventTypes = [ * while the grant at Google stood untouched. */ "mcp.account_disconnected", + /* + * One person retracted their own pending per-action authorization before it completed. + * + * The counterpart of `mcp.account_connected` on the same lifecycle: that row is the attempt + * ending in a yes, this one is the person deliberately ending it themselves. Without it, pressing + * Cancel deleted the attempt and nothing recorded who withdrew what — on a lifecycle whose whole + * point is that consent is attributable. Filed under the app's slug, as its two siblings are. + * + * NEVER THE CONSENT URL AND NEVER THE VENDOR'S HANDLE FOR THE FLOW: the first is a bearer + * capability, and neither is in the payload. `minted` says only whether a flow had been started at + * the vendor at all, which is the one fact about it a reader of the trail needs. + */ + "mcp.authorization_cancelled", // An administrator switched a brokered app between each person's account and one shared account. "mcp.account_mode_changed", // A Bot's use of a Shared app needs an administrator's approval: filed by a refused call or by the diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 34c5013d1..4fda000e5 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3041,6 +3041,17 @@ export function createPluginRoutes( actorId, createdAt: attempt.createdAt, }); + /* + * A RETRACTION IS ON THE TRAIL, AND ONLY ONE THAT HAPPENED. The person withdrawing their own + * pending consent is the other deliberate ending of this lifecycle besides completing it, and + * it is filed for the same reason `mcp.account_connected` is. Only when the deletion removed + * a row: a cancel that lost a race to a newer attempt removed nothing and withdrew nothing. + * After the deletion and not around it, as the disconnect route files its row, so a failed + * audit write fails the request rather than passing silently. + */ + if (cancelled) { + await store.recordAuthorizationCancelled({ actorId, attempt }); + } return context.json({ cancelled }); } const resolved = await brokeredAccountFor(context, serverId); diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 91156ae14..b48ea7c85 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -7011,6 +7011,52 @@ export function createPluginStore(options: PluginStoreOptions) { return row ?? null; }, + /** + * File `mcp.authorization_cancelled` for an attempt its own person has just retracted. + * + * CALLED ONLY AFTER {@link discardAuthorizationAttempt} ANSWERED TRUE, by the cancel route and by + * nothing else. The same deletion also tidies up after a refused or unrecorded mint, and those + * are this deployment cleaning up after itself, not a person withdrawing consent — so the row is + * filed here, for the one caller whose act it describes, and not inside the deletion. + * + * TARGETED AT THE APP'S SLUG, read off the attempt's server row with {@link toolkitOfFor}, which + * is the id `mcp.account_connected` and `mcp.account_disconnected` file a brokered app under, so + * one query on the target answers every ending of one app's consent. An attempt OUTLIVES what + * its row's url says — an administrator can re-point the row while a consent is in flight, and + * the cancel route deliberately still lets the person retract it — and once the url names no + * app the row's own id is the only name left for what was cancelled, so that is what the row is + * filed under; `serverId` is in the payload either way. + * + * WHO DID IT GOES IN THE PAYLOAD, as on every writer here, never in `actorUserId`. And the + * payload carries NEITHER the consent url NOR the attempt's `auth_id`: the attempt row never held + * the url, and the vendor's handle is not needed to say what a person withdrew. `minted` is + * whether a flow had been started at the vendor at all. + */ + async recordAuthorizationCancelled(input: { + actorId: string; + attempt: PendingAuthorization; + }): Promise { + const [row] = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.id, input.attempt.serverId)) + .limit(1); + const toolkit = row ? toolkitOfFor(brokerProvider, row.url) : null; + await recordAuditEvent(auditStore, { + eventType: "mcp.authorization_cancelled", + targetType: "mcp_server", + targetId: toolkit ?? input.attempt.serverId, + payload: { + actor: input.actorId, + server: toolkit, + serverId: input.attempt.serverId, + action: input.attempt.action, + holder: input.attempt.holder, + minted: input.attempt.authId !== null, + }, + }); + }, + /** * Spend one call on this person's key, and say what the vendor made of it. * diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 294f04421..2e926cceb 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -13,7 +13,12 @@ import { Hono, type MiddlewareHandler } from "hono"; import { createAuditStore } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; import { createDatabase } from "../src/db/client"; -import { mcpServers, mcpTools, pendingAuthorizations } from "../src/db/schema"; +import { + auditEvents, + mcpServers, + mcpTools, + pendingAuthorizations, +} from "../src/db/schema"; import type { BrokerProvider } from "../src/plugins/access"; import type { ConnectedAppBroker } from "../src/plugins/broker"; import { createPluginRoutes } from "../src/plugins/routes"; @@ -402,3 +407,75 @@ describe("DELETE /servers/:id/authorize", () => { } }); }); + +/* + * B5b. A RETRACTION IS ON THE TRAIL. The mint/claim/complete lifecycle exists to make a person's + * consent attributable, and `mcp.account_connected` records the one ending where they said yes. A + * person pressing Cancel on their own pending authorization is the other deliberate ending, and it + * used to leave nothing at all: the row was deleted and no record said who withdrew what. + * + * Read by the ACTOR in the payload, which is where every writer on this lifecycle puts who did it, + * so another test's rows on the same app can neither satisfy nor spoil these. + */ +describe("DELETE /servers/:id/authorize — the audit row", () => { + const cancellationsBy = (actorId: string) => + database + .select() + .from(auditEvents) + .where( + sql`${auditEvents.eventType} = 'mcp.authorization_cancelled' and ${auditEvents.payload}->>'actor' = ${actorId}`, + ); + + test("cancelling a live attempt files one mcp.authorization_cancelled row against the app", async () => { + const actor = await person("audited"); + const app = appAs("user", actor); + expect((await authorize(app, personalApp)).status).toBe(200); + + const response = await cancel(app, personalApp); + expect(await response.json()).toEqual({ cancelled: true }); + + const rows = await cancellationsBy(actor); + expect(rows).toHaveLength(1); + const [row] = rows; + // The app's slug, which is what `mcp.account_connected` and `mcp.account_disconnected` file a + // brokered app under — so one query on the target answers every ending of one app's consent. + expect(row?.targetType).toBe("mcp_server"); + expect(row?.targetId).toBe(`P${suite}`); + expect(row?.initiatorKind).toBe("person"); + expect(row?.payload).toEqual({ + actor, + server: `P${suite}`, + serverId: personalApp, + action: ACTION, + holder: "person", + minted: true, + }); + // THE CONFIDENTIALITY PROMISE: neither the bearer consent url nor the vendor's handle for the + // flow reaches the trail. + const written = JSON.stringify(row); + expect(written).not.toContain(SECRET_URL); + expect(written).not.toContain("ar_minted_1"); + }); + + test("a cancel that loses the race to a newer attempt removes nothing and files no row", async () => { + const actor = await person("unaudited"); + const app = appAs("user", actor); + expect((await authorize(app, personalApp)).status).toBe(200); + const [old] = await attemptsFor(actor); + if (!old) throw new Error("no first attempt"); + // The cancel resolved an attempt that a newer one has since replaced: its delete matches nothing. + const stale = { + ...old, + createdAt: new Date(old.createdAt.getTime() - 16 * 60 * 1000), + }; + const racing = appAs("user", actor, "arcade", (store) => ({ + ...store, + authorizationAttemptFor: async () => stale, + })); + expect(await (await cancel(racing, personalApp)).json()).toEqual({ + cancelled: false, + }); + expect(await attemptsFor(actor)).toHaveLength(1); + expect(await cancellationsBy(actor)).toEqual([]); + }); +}); From 7fa121b5b0300e9718f26b6404dd5a24e30acb4f Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 12:14:10 +0200 Subject: [PATCH 200/232] fix(plugins): carry vendor_user_id and skill origin through their conflict arms B4a. Three upserts set a column on INSERT and omitted it from onConflictDoUpdate, so the column was right on the first write and stale on every later one. - recordBrokeredConnection: vendor_user_id now carried on both the person and deployment arms. connectionTokenFor sends calls under the ROW's id, so a reconnect under a new identity used to write a verdict about one account onto a row naming another. connected_by keeps its existing conditional supersede (unchanged; now guarded by a test). The `only: "a row that is still there"` re-check UPDATE deliberately does NOT carry vendor_user_id - a check is not a grant - and is commented and guarded as such. - completeAuthorizationAttempt: vendor_user_id carried on both arms (connected_by on the deployment arm was already fixed in wave 1 B3). - installSkill: origin carried as `input.origin ?? "yours"`. Leaving 'catalogue' standing after an edit kept the row eligible for the package seed's setWhere overwrite, so the next boot discarded the edit. installed_by and owner_user_id stay first-write by design. No signatures changed. Call sites whose behaviour changes: recordBrokeredConnection - store.ts confirmBrokeredConnection (x2), the connect/verify paths (x2), recheckBrokeredConnection (only-branch, unchanged); completeAuthorizationAttempt - plugins/routes.ts Arcade return leg; installSkill - plugins/routes.ts POST /skills. Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/store.ts | 57 ++++++++++-- ...completion-transaction.integration.test.ts | 51 +++++++++++ .../shared-brokered-store.integration.test.ts | 89 +++++++++++++++++++ .../tests/skill-ownership.integration.test.ts | 38 ++++++++ 4 files changed, 229 insertions(+), 6 deletions(-) diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 91156ae14..69c359cf7 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -5500,12 +5500,26 @@ export function createPluginStore(options: PluginStoreOptions) { }) // Editing keeps the owner it already had. Whose a skill is, is not something a re-save // should quietly change, and the route has already checked this person may edit it. + // `installed_by` is left alone too: it records who first installed it, not who last saved. .onConflictDoUpdate({ target: skills.slug, set: { title: input.title, summary: input.summary, instructions: input.instructions, + /* + * WHERE THE TEXT NOW COMES FROM, WRITTEN BESIDE THE TEXT. + * + * CRITERION. A save through here leaves `origin` saying what it says on an insert. + * + * REASON. `origin = 'catalogue'` is the condition the package seed (`tenant-package.ts`, + * `setWhere`) overwrites a skill on at every boot. Leaving it standing after somebody + * edited a shipped skill here kept the row eligible, so the next boot silently replaced + * their edit with the package's text — the exact outcome that seed's own comment says + * it exists to prevent ("rather than the package silently replacing something somebody + * wrote"). + */ + origin: input.origin ?? "yours", updatedAt: new Date(), }, }); @@ -6525,6 +6539,11 @@ export function createPluginStore(options: PluginStoreOptions) { * AN UPDATE AND NEVER AN INSERT, and `returning` is how "there was a row" is ANSWERED rather * than assumed. An update that matches nothing is not a failure — it is the row having been * deleted while the vendor was being asked — and the caller has a sentence for that. + * + * `vendor_user_id` IS DELIBERATELY NOT IN THIS `set`. This branch records a CHECK of a + * connection that already exists, never a connection; it may not re-point the row at a + * different vendor identity, because only a connect or a confirm (the upsert below) is + * evidence of a grant held under the identity it names. */ const written = await database .update(brokeredConnections) @@ -6534,6 +6553,20 @@ export function createPluginStore(options: PluginStoreOptions) { return { verifiedAt, wrote: written.length > 0 }; } + /* + * THE VENDOR IDENTITY THIS WRITE'S EVIDENCE IS ABOUT, WRITTEN ON BOTH BRANCHES OF THE UPSERT. + * + * CRITERION. After this upsert the row names `input.account.vendorUserId`, whatever it named + * before. + * + * REASON. Every caller of the upsert has just heard from the vendor about the account held + * under THIS id — a consent, or the vendor confirming it holds an account — and + * `connectionTokenFor` sends calls out under the ROW's `vendor_user_id`, never a recomputed + * one. A conflict arm that rewrote `verified`/`verified_at` and left the old id standing + * wrote a verdict about one account onto a row naming another, and every later call landed + * in the account the evidence was not about. The insert always set it; the arms did not. + */ + const connectedAs = input.account.vendorUserId; await database .insert(brokeredConnections) .values({ @@ -6574,7 +6607,7 @@ export function createPluginStore(options: PluginStoreOptions) { brokeredConnections.userId, ], targetWhere: sql`${brokeredConnections.holder} = 'person'`, - set, + set: { ...set, vendorUserId: connectedAs }, } : { target: [brokeredConnections.provider, brokeredConnections.app], @@ -6584,10 +6617,15 @@ export function createPluginStore(options: PluginStoreOptions) { // which `set` otherwise never said, leaving the row crediting whoever // connected it the first time. Added only when this call names a // `connectedBy` at all, so a re-check or a confirm that passes none does not - // blank out the administrator an earlier connect already recorded. + // blank out the administrator an earlier connect already recorded. That + // condition is the point: do NOT make this unconditional. set: input.connectedBy - ? { ...set, connectedBy: input.connectedBy ?? null } - : set, + ? { + ...set, + vendorUserId: connectedAs, + connectedBy: input.connectedBy, + } + : { ...set, vendorUserId: connectedAs }, }, ); return { verifiedAt, wrote: true }; @@ -6866,7 +6904,10 @@ export function createPluginStore(options: PluginStoreOptions) { brokeredConnections.userId, ], targetWhere: sql`${brokeredConnections.holder} = 'person'`, - set, + // The identity the grant was just made under, for `recordBrokeredConnection`'s + // reason: calls go out under the row's id, so a re-consent that left the old one + // standing sent them to an account this consent says nothing about. + set: { ...set, vendorUserId: input.account.vendorUserId }, } : { target: [ @@ -6874,7 +6915,11 @@ export function createPluginStore(options: PluginStoreOptions) { brokeredConnections.app, ], targetWhere: sql`${brokeredConnections.holder} = 'deployment'`, - set: { ...set, connectedBy }, + set: { + ...set, + vendorUserId: input.account.vendorUserId, + connectedBy, + }, }, ); diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index da0ad42bb..1f69eae34 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -313,6 +313,57 @@ describe("completeAuthorizationAttempt", () => { expect(connections[0]?.connectedBy).toBe(second); }); + /* + * THE ROW NAMES THE IDENTITY THE LATEST CONSENT WAS GIVEN UNDER. Calls go out under the row's + * `vendor_user_id` (`connectionTokenFor` reads it back rather than recomputing it), so a + * re-consent under a new identity that rewrote `verified`/`verified_at` and left the old id + * standing sent every later call to an account this consent says nothing about. Both holders, + * because both conflict arms used to omit it. + */ + test.each(["deployment", "person"] as const)( + "a %s re-consent under a NEW vendor identity moves the row to it", + async (holder) => { + const a = await actor(`reid-${holder}`); + const app = toolkit(`reid-${holder}`); + const as = (vendorUserId: string): AccountRef => + holder === "deployment" + ? { holder, vendorUserId } + : { holder, userId: a, vendorUserId }; + + const consent = async (authId: string, ref: AccountRef) => { + const claimed = await store.claimAuthorizationAttempt({ + actorId: a, + serverId, + action: "Google.SendEmail", + account: ref, + }); + if (!claimed.claimed) throw new Error("claim failed"); + const minted = await store.recordAuthorizationMint({ + actorId: a, + createdAt: claimed.attempt.createdAt, + authId, + }); + if (!minted) throw new Error("mint failed"); + await store.completeAuthorizationAttempt({ + actorId: a, + authId, + toolkit: app, + account: ref, + action: "Google.SendEmail", + scopes: ["gmail.send"], + authProvider: "google", + }); + }; + + await consent(`ar_reid_${holder}_1`, as(`old-${app}`)); + await consent(`ar_reid_${holder}_2`, as(`new-${app}`)); + + const connections = await connectionsFor(app); + expect(connections.length).toBe(1); + expect(connections[0]?.vendorUserId).toBe(`new-${app}`); + }, + ); + test("two CONCURRENT calls write exactly one connection and one audit event, and the loser answers as the winner would", async () => { const a = await actor("race"); const app = toolkit("race"); diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts index 3171f76ab..72afa3df3 100644 --- a/server/tests/shared-brokered-store.integration.test.ts +++ b/server/tests/shared-brokered-store.integration.test.ts @@ -235,6 +235,95 @@ describe("a shared app", () => { expect(rows).toEqual([{ connectedBy: "admin-b" }]); }); + /* + * THE SUPERSEDE IS CONDITIONAL, AND THAT IS ITS OWN GUARD. A confirm or re-check that names no + * administrator must not blank out the one an earlier connect recorded. + */ + test("a write that names no administrator keeps the one already recorded", async () => { + const store = freshStore(); + await store.recordBrokeredConnection({ + toolkit: app, + account: { holder: "deployment", vendorUserId: VENDOR_ID }, + connectedBy: "admin-a", + verified: true, + probeAction: null, + }); + await store.recordBrokeredConnection({ + toolkit: app, + account: { holder: "deployment", vendorUserId: VENDOR_ID }, + verified: false, + probeAction: null, + }); + const rows = await database + .select({ connectedBy: brokeredConnections.connectedBy }) + .from(brokeredConnections) + .where(eq(brokeredConnections.app, app)); + expect(rows).toEqual([{ connectedBy: "admin-a" }]); + }); + + /* + * THE ROW NAMES THE IDENTITY THE LATEST CONNECTION WAS MADE UNDER. Calls go out under the row's + * `vendor_user_id`, so a reconnect under a new identity that rewrote the verdict and kept the old + * id sent every later call to the account the verdict was not about. + */ + test.each(["deployment", "person"] as const)( + "a %s reconnect under a NEW vendor identity moves the row to it", + async (holder) => { + const store = freshStore(); + const as = (vendorUserId: string) => + holder === "deployment" + ? { holder, vendorUserId } + : { holder, userId: asker, vendorUserId }; + await store.recordBrokeredConnection({ + toolkit: app, + account: as(`old-${suite}`), + connectedBy: "admin-a", + verified: true, + probeAction: null, + }); + await store.recordBrokeredConnection({ + toolkit: app, + account: as(`new-${suite}`), + connectedBy: "admin-b", + verified: true, + probeAction: null, + }); + const rows = await database + .select({ vendorUserId: brokeredConnections.vendorUserId }) + .from(brokeredConnections) + .where(eq(brokeredConnections.app, app)); + expect(rows).toEqual([{ vendorUserId: `new-${suite}` }]); + }, + ); + + /* + * AND A RE-CHECK NEVER RE-POINTS IT. The `only` branch records a check of a connection that + * exists; it is not evidence of a grant under any identity, so it may not move the row's. + */ + test("a re-check write under another identity leaves the row's identity alone", async () => { + const store = freshStore(); + await store.recordBrokeredConnection({ + toolkit: app, + account: { holder: "deployment", vendorUserId: VENDOR_ID }, + connectedBy: "admin-a", + verified: true, + probeAction: null, + }); + const { wrote } = await store.recordBrokeredConnection({ + toolkit: app, + account: { holder: "deployment", vendorUserId: `other-${suite}` }, + verified: false, + probeAction: "GITHUB_LIST_ISSUES", + only: "a row that is still there", + }); + expect(wrote).toBe(true); + const rows = await database + .select({ vendorUserId: brokeredConnections.vendorUserId }) + .from(brokeredConnections) + .where(eq(brokeredConnections.app, app)); + expect(rows).toEqual([{ vendorUserId: VENDOR_ID }]); + }); + test("removing the app revokes the deployment account too", async () => { const store = freshStore(); await seedShared(store, true); diff --git a/server/tests/skill-ownership.integration.test.ts b/server/tests/skill-ownership.integration.test.ts index 871004d1b..33049383d 100644 --- a/server/tests/skill-ownership.integration.test.ts +++ b/server/tests/skill-ownership.integration.test.ts @@ -95,6 +95,7 @@ afterAll(async () => { bobSkill, deploymentSkill, `written-${suite}`, + `catalogue-${suite}`, ]), ); await database @@ -163,6 +164,43 @@ describe("editing keeps ownership", () => { expect(await store.skillOwner(aliceSkill)).toBe(alice); }); + + /* + * A SHIPPED SKILL SOMEBODY EDITED IS NO LONGER THE CATALOGUE'S. `origin = 'catalogue'` is what the + * package seed overwrites at every boot, so a re-save that left it standing handed the edit to the + * next boot to throw away. Seeded the way `tenant-package.ts` seeds it, then saved the way the + * route saves it — which names no origin. + */ + test("saving over a catalogue skill makes it the deployment's own", async () => { + const slug = `catalogue-${suite}`; + await database.insert(skills).values({ + id: slug, + slug, + ownerUserId: null, + title: "Shipped", + summary: "", + instructions: "As shipped.", + origin: "catalogue", + installedBy: null, + }); + + await store.installSkill({ + slug, + title: "Edited here", + summary: "", + instructions: "As an administrator rewrote it.", + ownerUserId: null, + by: "admin@example.test", + }); + + const [row] = await database + .select({ origin: skills.origin, installedBy: skills.installedBy }) + .from(skills) + .where(inArray(skills.slug, [slug])); + expect(row?.origin).toBe("yours"); + // Who first installed it is a first-write fact, and a re-save does not rewrite it. + expect(row?.installedBy).toBeNull(); + }); }); /** From 0557dab6f34f1c737c92726c3accf74b3f131dbd Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 12:14:24 +0200 Subject: [PATCH 201/232] fix(plugins): log a context-changed withdrawal whose outcome is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /arcade/verify context-changed branch read a throw out of broker.revoke as `withdrawn = false` in a bare catch and said nothing. A throw is not "nothing withdrawn": the Arcade seam deletes every connection it found and only then throws on any failure, and a vendor deadline abandons a call the vendor may still finish, so the vendor side may already be withdrawn while this deployment's rows for the app and its siblings stand. Same commit-then-throw shape as wave 1's B6. The outcome stays conservative (no rows cleared, attempt closed `unreconciled`, the existing "we do not know" verdict); what was missing is the trace. The catch now logs `arcade-context-withdrawal-unknown` with server, app, action, authorization id, sibling apps whose rows may be stale, and the reason via reasonWithoutStatement — the treatment its siblings on this route already get. No third outcome value: that would be a schema change, and `unreconciled` is already exactly true here. Call sites: no signature changed. broker.revoke is called from routes.ts (this branch only) and store.ts (disconnect, removeServer loop, offboarding); none of those were touched. Test: arcade-verify.test.ts "a revoke that withdrew part and then threw clears nothing, and leaves an operator a line". Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/routes.ts | 53 ++++++++++++++++++++++++++++-- server/tests/arcade-verify.test.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 34c5013d1..a7a9c1ad6 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -3387,9 +3387,58 @@ export function createPluginRoutes( toolkit: retiredApp, }), ); - } catch { - // Nothing from the vendor's answer travels with this, here as everywhere on this leg. + } catch (error) { + /* + * A THROW HERE IS NOT AN ANSWER OF `false`, AND IT IS NOT READ AS ONE. + * + * `false` is the broker saying "there was nothing to withdraw", off a listing it read + * whole. A throw says nothing of the kind: the seam deletes EVERY connection it found + * and only then throws if any delete failed, so "revoke failed for 1 of 3" arrives + * here with two of three already gone; and a deadline that fires is this deployment + * giving up on a call the vendor may finish a moment later. Either way the vendor side + * may already be withdrawn — the commit-then-throw shape `/arcade/verify`'s completion + * catch below was fixed for, one call earlier on the same leg. + * + * SO THE OUTCOME IS NEITHER CLAIM. `withdrawn` stays false, which is the conservative + * half: no local row is cleared and no collateral is put on the trail on the strength + * of a withdrawal nobody saw, because the alternative — clearing on a guess — is + * this deployment forgetting a grant that may still be live, and that is the + * irreversible mistake. The attempt is closed `unreconciled`, which is the existing + * verdict for "we do not know what happened" and is exactly true here. + * + * WHAT `unreconciled` DOES NOT COVER, AND THIS LINE DOES. The row holds an outcome and + * a timestamp and nothing else: no reason, nothing reads it back, and it reads the same + * for a vendor that said "nothing there" as for a call whose outcome is unknown. It + * cannot say the one thing an operator needs here — that the vendor may ALREADY have + * ended this connection, so the rows this deployment still holds for the app and for + * every sibling on the same connection may be claiming live connections that are gone, + * or that the grant is live and needs ending by hand. That is a disagreement between + * this deployment and the vendor that nobody would otherwise find out about, and + * leaving no trace of it was half of the defect. A third outcome on the row would need + * a schema change to say what this line says, and the line is where every sibling on + * this route already reports a failure it swallowed. + * + * WHAT IS IN THE LINE is what `arcade-completion-not-recorded` allows itself and the + * names of this deployment's own apps: the authorization id an operator finds the flow + * by, the app, the sibling apps whose rows may now be stale, and the reason through + * {@link reasonWithoutStatement}. Errors out of the broker seam carry this deployment's + * own sentence — `ArcadeFailureError` (`arcade.ts`) drops the vendor's prose by construction + * and {@link VendorTimeoutError} is ours — so nothing of the vendor's answer travels + * with this, here as everywhere on this leg. No account id, no vendor user id. + */ withdrawn = false; + console.error( + JSON.stringify({ + type: "arcade-context-withdrawal-unknown", + server: attempt.serverId, + app: retiredApp, + action: attempt.action, + authorizationId: authId, + siblings: collateral.siblings, + note: "A consent completed at the vendor after this app's account changed, and withdrawing the retired identity's grant raised instead of answering. Whether the vendor withdrew it is unknown: the attempt was closed unreconciled and no row here was cleared, so either the grant is still live at the vendor or this deployment's rows for the app and the siblings named here claim connections the vendor has already ended. Check the connection at the vendor and disconnect the app here to settle it.", + error: reasonWithoutStatement(error), + }), + ); } } /* diff --git a/server/tests/arcade-verify.test.ts b/server/tests/arcade-verify.test.ts index a202ab0e2..32a017c38 100644 --- a/server/tests/arcade-verify.test.ts +++ b/server/tests/arcade-verify.test.ts @@ -1154,6 +1154,52 @@ describe("a consent whose account context has moved on", () => { expect((await attemptRow())?.outcome).toBe("unreconciled"); }); + /* + * B6a: A REVOKE THAT THROWS IS AN OUTCOME NOBODY KNOWS, NOT AN ANSWER OF `false`. + * + * The seam deletes every connection it found and only then throws if any delete failed, so this + * throw arrives with part of the withdrawal already done at the vendor. The route used to read + * it as `false` in a bare `catch` and say nothing: the attempt went `unreconciled`, the rows here + * stood, and an operator had no way to learn that this deployment and the vendor may now + * disagree about whether the connection exists. + */ + test("a revoke that withdrew part and then threw clears nothing, and leaves an operator a line", async () => { + await giveAttempt({ authId: MINTED, ageMinutes: 1 }); + await switchToShared(); + await hold(TOOLKIT, ACTOR); + await hold(siblingApp(), ACTOR); + revokeAnswer = async () => { + throw new Error("Arcade revoke failed for 1 of 3 connections."); + }; + + const [response, said] = await heard(() => + verify(ACTOR, "?flow_id=flow-abc"), + ); + await expectRefusal(response, "context-changed"); + + // NOT READ AS A WITHDRAWAL: nothing cleared, nothing claimed on the trail, kept as unknown. + expect(await rowsFor([TOOLKIT, siblingApp()], ACTOR)).toEqual( + [TOOLKIT, siblingApp()].sort(), + ); + expect(await disconnectsFor([TOOLKIT, siblingApp()])).toEqual([]); + expect((await attemptRow())?.outcome).toBe("unreconciled"); + + // AND SAID OUT LOUD, with what an operator needs to find the disagreement and settle it. + const line = lines(said).find((text) => + text.includes("arcade-context-withdrawal-unknown"), + ); + expect(line).toBeDefined(); + expect(line).toContain(`"authorizationId":"${MINTED}"`); + // Quoted as the field, because the sibling's name carries TOOLKIT as its prefix. + expect(line).toContain(`"app":"${TOOLKIT}"`); + // CONTAINED RATHER THAN EQUAL: siblings are read off every row this identity holds, and earlier + // cases in this file leave theirs until `afterAll`. + expect( + (JSON.parse(line ?? "{}") as { siblings?: string[] }).siblings, + ).toContain(siblingApp()); + expect(line).toContain("Arcade revoke failed for 1 of 3 connections."); + }); + test("a sibling set this deployment could not resolve stops the revoke rather than widening it", async () => { await giveAttempt({ authId: MINTED, ageMinutes: 1 }); await switchToShared(); From 2a42273db5252ded3123fc3d028743290c4f000a Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 12:55:34 +0200 Subject: [PATCH 202/232] test(broker): complete the two enable-path broker stubs, and record any reach `brokered-app-provider.integration.test.ts` and `store-scheme-sites.test.ts` each built a `ComposioBroker` with no `connectionFields`, `connectWithFields` or `revokeAccount`. The broken `import type` C8 repointed had made the type `any`, so nothing saw it. Neither suite asserts on any of the three. The paths they drive (`addBrokeredApp`, `addCustomServer`, `brokeredConnectionsFor`, `probeBrokeredConnection`) reach only `ensureAuthConfig`, and `store.ts` calls `revokeAccount` from nowhere, by documented decision (broker.ts:803-813). The gap was in detection. Measured at base: a planted `try { await broker.revokeAccount("PLANTED") } catch {}` in `addBrokeredApp` left both suites at 26 pass / 0 fail. Each missing member now throws AND records, and an `afterEach` asserts the record is empty, so the same plant fails 9 of 26 tests whether or not production swallows the throw. Baseline: the two TS2739 entries are removed. The two credential-stub TS2322 entries in these files are kept, renumbered (+20) for the inserted lines; their messages are unchanged. 267 -> 265. Call sites: no production signature changed. The stubs are local to each file. Co-Authored-By: Claude Opus 5.5 --- .../brokered-app-provider.integration.test.ts | 20 +++++++++++++++++++ server/tests/store-scheme-sites.test.ts | 20 +++++++++++++++++++ server/tests/typecheck-baseline.txt | 6 ++---- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/server/tests/brokered-app-provider.integration.test.ts b/server/tests/brokered-app-provider.integration.test.ts index 38cb031c1..1ea6b2162 100644 --- a/server/tests/brokered-app-provider.integration.test.ts +++ b/server/tests/brokered-app-provider.integration.test.ts @@ -40,9 +40,23 @@ afterAll(async () => { await database.$client.close(); }); +/** + * Every broker member this file does not expect production to reach, by the name it was asked as. + * + * EACH ONE THROWS AND RECORDS, because a throw is only as loud as the path that receives it: a + * `catch` around the call swallows it and the test passes on a broker that was never there. That is + * how three members of `ConnectedAppBroker` — `revokeAccount` among them — were absent from this + * stub without a single test noticing. The check below reads the record after every test, so a + * reach is a red whether or not production let the throw out. + */ +const reached: string[] = []; const unasked = (what: string) => async (): Promise => { + reached.push(what); throw new Error(`asked the broker to ${what}`); }; +afterEach(() => { + expect(reached.splice(0)).toEqual([]); +}); function storeFor( provider: BrokerProvider, @@ -63,6 +77,12 @@ function storeFor( authorize: unasked("authorize"), isConnected: unasked("check"), revoke: unasked("revoke"), + // Nothing here draws a connect form, connects with typed fields or undoes one account. All three + // are declared so the stub is a `ConnectedAppBroker` rather than a partial one, and all three + // fail if reached. + connectionFields: unasked("read connection fields"), + connectWithFields: unasked("connect with typed fields"), + revokeAccount: unasked("revoke one account"), accountName: async () => null, }; return createPluginStore({ diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts index a3362dc12..6af248950 100644 --- a/server/tests/store-scheme-sites.test.ts +++ b/server/tests/store-scheme-sites.test.ts @@ -54,9 +54,23 @@ afterAll(async () => { await database.$client.close(); }); +/** + * Every broker member this file does not expect production to reach, by the name it was asked as. + * + * EACH ONE THROWS AND RECORDS, because a throw is only as loud as the path that receives it: a + * `catch` around the call swallows it and the test passes on a broker that was never there. That is + * how three members of `ConnectedAppBroker` — `revokeAccount` among them — were absent from this + * stub without a single test noticing. The check below reads the record after every test, so a + * reach is a red whether or not production let the throw out. + */ +const reached: string[] = []; const unasked = (what: string) => async (): Promise => { + reached.push(what); throw new Error(`asked the broker to ${what}`); }; +afterEach(() => { + expect(reached.splice(0)).toEqual([]); +}); function storeFor(provider: BrokerProvider) { const broker: ComposioBroker = { @@ -73,6 +87,12 @@ function storeFor(provider: BrokerProvider) { authorize: unasked("authorize"), isConnected: unasked("check"), revoke: unasked("revoke"), + // Nothing here draws a connect form, connects with typed fields or undoes one account. All three + // are declared so the stub is a `ConnectedAppBroker` rather than a partial one, and all three + // fail if reached. + connectionFields: unasked("read connection fields"), + connectWithFields: unasked("connect with typed fields"), + revokeAccount: unasked("revoke one account"), accountName: async () => null, }; return createPluginStore({ diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index 8c50a8180..42639698b 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -69,8 +69,7 @@ server/tests/bot-lifecycle-audit.test.ts:301: error TS2345: Argument of type '{ server/tests/bot-lifecycle-audit.test.ts:315: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. server/tests/bot-lifecycle-audit.test.ts:326: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. server/tests/bot-lifecycle-audit.test.ts:335: error TS2345: Argument of type '{ revoke: (id: string) => Promise; }' is not assignable to parameter of type 'Pick'. -server/tests/brokered-app-provider.integration.test.ts:51: error TS2739: Type '{ grantIsLocal: true; authProviderOf: () => Promise; authorizations: null; grantsFor: () => Promise; listApps: () => Promise; ensureAuthConfig: () => Promise<...>; ... 4 more ...; accountName: () => Promise<...>; }' is missing the following properties from type 'ConnectedAppBroker': connectionFields, connectWithFields, revokeAccount -server/tests/brokered-app-provider.integration.test.ts:72: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/brokered-app-provider.integration.test.ts:92: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/channel-routes.test.ts:385: error TS2769: No overload matches this call. server/tests/channel-routes.test.ts:86: error TS2322: Type '(receivedActor: AgentActor, id: string) => Promise' is not assignable to type '(actor: AgentActor, channelId: string) => Promise'. server/tests/channel-routes.test.ts:919: error TS2769: No overload matches this call. @@ -257,8 +256,7 @@ server/tests/settings-instructions-routes.test.ts:314: error TS7006: Parameter ' server/tests/skill-ownership.integration.test.ts:29: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/skill-tools.integration.test.ts:35: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/skill-uninstall-grants.integration.test.ts:26: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/store-scheme-sites.test.ts:62: error TS2739: Type '{ grantIsLocal: true; authProviderOf: () => Promise; authorizations: null; grantsFor: () => Promise; listApps: () => Promise; ensureAuthConfig: () => Promise<...>; ... 4 more ...; accountName: () => Promise<...>; }' is missing the following properties from type 'ConnectedAppBroker': connectionFields, connectWithFields, revokeAccount -server/tests/store-scheme-sites.test.ts:82: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/store-scheme-sites.test.ts:102: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/tenant-package.test.ts:1440: error TS2698: Spread types may only be created from object types. server/tests/tenant-package.test.ts:1518: error TS2698: Spread types may only be created from object types. server/tests/thread-routes.test.ts:22: error TS2769: No overload matches this call. From 8c8ee8414f22b2970e8b326509f1467910c4b684 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 13:01:59 +0200 Subject: [PATCH 203/232] test(broker): complete the third enable-path broker stub in plugin-store The same defect as the previous commit, in a third stub that the C8a prompt did not name: `plugin-store.integration.test.ts`'s "enabling an app writes a brokered row" stub also lacked `connectionFields`, `connectWithFields` and `revokeAccount` (baseline TS2739 at :6614). Its path reaches only `ensureAuthConfig`. The three members now throw through the test's own `unasked`. Kept as its own commit so it can be dropped if another wave-3 agent owns this file. Baseline: the TS2739 entry is removed, and six later entries in this file are renumbered (+6) with their messages unchanged. 265 -> 264. Co-Authored-By: Claude Opus 5.5 --- server/tests/plugin-store.integration.test.ts | 6 ++++++ server/tests/typecheck-baseline.txt | 13 ++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 6ccae1fc0..1a79cc7ef 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -6640,6 +6640,12 @@ test("enabling an app writes a brokered row, and asks for its auth config first" authorize: unasked("begin somebody's connection"), isConnected: unasked("check somebody's connection"), revoke: unasked("withdraw somebody's grant"), + // Enabling an app draws no connect form, connects nobody and undoes no account. All three are + // declared so the stub is a `ConnectedAppBroker` rather than a partial one, and all three fail + // if reached. + connectionFields: unasked("read an app's connection fields"), + connectWithFields: unasked("connect somebody with typed fields"), + revokeAccount: unasked("undo one account"), accountName: async () => null, }; diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index 42639698b..792e9a544 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -197,13 +197,12 @@ server/tests/plugin-store.integration.test.ts:6257: error TS2556: A spread argum server/tests/plugin-store.integration.test.ts:6264: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/plugin-store.integration.test.ts:6399: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/plugin-store.integration.test.ts:6548: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/plugin-store.integration.test.ts:6614: error TS2739: Type '{ grantIsLocal: true; authProviderOf: () => Promise; authorizations: null; grantsFor: () => Promise; listApps: () => Promise; ensureAuthConfig: (config: { ...; }) => Promise<...>; ... 4 more ...; accountName: () => Promise<...>; }' is missing the following properties from type 'ConnectedAppBroker': connectionFields, connectWithFields, revokeAccount -server/tests/plugin-store.integration.test.ts:6721: error TS2345: Argument of type '{ slug: string; title: string; by: string; }' is not assignable to parameter of type '{ slug: string; title: string; logo?: string | null | undefined; by: string; connection: BrokerConnection; }'. -server/tests/plugin-store.integration.test.ts:6806: error TS2322: Type '(request: { toolkit: string; name: string; }) => Promise' is not assignable to type '(config: { toolkit: string; name: string; connection: BrokerConnection; }) => Promise'. -server/tests/plugin-store.integration.test.ts:7008: error TS2345: Argument of type 'string | null' is not assignable to parameter of type 'string'. -server/tests/plugin-store.integration.test.ts:7332: error TS2322: Type '() => Promise' is not assignable to type '(config: { toolkit: string; name: string; }) => Promise'. -server/tests/plugin-store.integration.test.ts:7335: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. -server/tests/plugin-store.integration.test.ts:7339: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. +server/tests/plugin-store.integration.test.ts:6727: error TS2345: Argument of type '{ slug: string; title: string; by: string; }' is not assignable to parameter of type '{ slug: string; title: string; logo?: string | null | undefined; by: string; connection: BrokerConnection; }'. +server/tests/plugin-store.integration.test.ts:6812: error TS2322: Type '(request: { toolkit: string; name: string; }) => Promise' is not assignable to type '(config: { toolkit: string; name: string; connection: BrokerConnection; }) => Promise'. +server/tests/plugin-store.integration.test.ts:7014: error TS2345: Argument of type 'string | null' is not assignable to parameter of type 'string'. +server/tests/plugin-store.integration.test.ts:7338: error TS2322: Type '() => Promise' is not assignable to type '(config: { toolkit: string; name: string; }) => Promise'. +server/tests/plugin-store.integration.test.ts:7341: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. +server/tests/plugin-store.integration.test.ts:7345: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. server/tests/plugin-user-credential.integration.test.ts:98: error TS2322: Type '{ readSecret: (id: string) => Promise<{ encryptedValue: string; revokedAt: Date | null; }>; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise<...>; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/policy-dry-run.test.ts:24: error TS2739: Type '{ id: string; actorUserId: null; eventType: string; targetType: string; targetId: string; createdAt: string; payload: Record; }' is missing the following properties from type 'AuditEvent': initiatorKind, initiatorId server/tests/remote-approval-context.test.ts:11: error TS2345: Argument of type '(input: RunAgentInput) => Observable<{ type: string; threadId: string; runId: string; } | { type: string; snapshot: { real: string; }; } | { type: string; toolCallId: string; toolCallName: string; parentMessageId: string; } | { ...; } | { ...; } | { ...; } | { ...; }>' is not assignable to parameter of type '(input: RunAgentInput) => Observable'. From 42e8e25744b0b7ec6673abc88c476f8ef270e2ba Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 13:02:32 +0200 Subject: [PATCH 204/232] fix(grants): count only what an approval covers in "approves all N" 6d349eff made an action that publishes no requirements.authorization answer `authorized` with no scopes, where it used to answer `unknown`. consentActionsFrom admitted every `authorized` grant, so those no-auth actions joined the consent set: in an app with no scoped actions the consent copy "approving this approves all N" grew by every action that needs no authorization at all. Consent text was telling a person they approve actions nothing ever withheld. N now means the actions an approval genuinely covers: `needs-consent`, plus `authorized` whose authorization is HELD. To make that decidable, the `authorized` GrantState carries a required `authorization: "held" | "not-required"` (server broker.ts and the app's mirror type). One predicate, coveredByApproval (app grants.ts), defines the set; consentActionsFrom uses it, and its docblock says what N is so the next change to requiresAuthorization cannot move it silently. Siblings audited and made to agree: - groupGrants' "whole-app" group ("Everything in this app, approved together") excluded no-auth actions too; they now draw in a "none" group, the same set the count leaves out. - GrantList row text: a not-required action reads "Needs no authorization." instead of "Authorized.". - consentBreadth (catalogue) NOT changed: it reads server-measured counts and over-counts no-auth actions independently of 6d349eff; reported, not fixed (needs a new BrokerApp count). Call sites of the changed GrantState (`authorized` constructors/readers): - arcade-adapter.ts grantStateOf: none -> not-required; completed/active -> held. - composio-adapter.ts grantsFor: authorized -> held (a held account). - routes.ts GET /servers/:id/grants: passes through; its synthetic entry is `unknown`, unaffected. - app grants.ts consentActionsFrom, groupGrants, scopesOf; grant-list.tsx describe and filters (state-only filters unchanged). Tests: arcade-consent-copy.test.tsx pins N against compositions of covered vs no-auth actions (red on base; red on a planted `state === "authorized"` predicate); connected-account-grants pins the rendered sentence (base drew "approves all 6", expected 3). The fixture helper no longer turns Apollo (no authorization block) into a consent action. Server fixtures gain the field; arcade-adapter-status pins not-required for the no-auth rows. Co-Authored-By: Claude Opus 5.5 --- app/src/components/plugins/grant-list.tsx | 5 +- app/src/lib/plugins/enablement-notes.ts | 9 +- app/src/lib/plugins/grants.ts | 67 ++++++++++-- app/tests/arcade-consent-copy.test.tsx | 103 +++++++++++++++++- app/tests/connected-account-grants.test.tsx | 71 +++++++++++- server/src/plugins/arcade-adapter.ts | 16 ++- server/src/plugins/broker.ts | 15 ++- server/src/plugins/composio-adapter.ts | 19 +++- server/tests/arcade-adapter-status.test.ts | 22 +++- ...ade-empty-vendor-structures-engine.test.ts | 7 +- server/tests/arcade-grants-route.test.ts | 7 +- server/tests/composio-grants-for.test.ts | 11 +- 12 files changed, 319 insertions(+), 33 deletions(-) diff --git a/app/src/components/plugins/grant-list.tsx b/app/src/components/plugins/grant-list.tsx index e98fc238e..98ad573dd 100644 --- a/app/src/components/plugins/grant-list.tsx +++ b/app/src/components/plugins/grant-list.tsx @@ -286,7 +286,10 @@ function ConsentNotice({ function describe(grant: GrantState, canAuthorize: boolean): string { switch (grant.state) { case "authorized": - return "Authorized."; + // Nobody authorized an action that needs no authorization, so the row does not say they did. + return grant.authorization === "not-required" + ? "Needs no authorization." + : "Authorized."; case "needs-consent": return canAuthorize ? "Not authorized yet." diff --git a/app/src/lib/plugins/enablement-notes.ts b/app/src/lib/plugins/enablement-notes.ts index f7e98c052..609f6724b 100644 --- a/app/src/lib/plugins/enablement-notes.ts +++ b/app/src/lib/plugins/enablement-notes.ts @@ -93,7 +93,14 @@ export function matchingApps(apps: ComposioApp[]): ComposioApp[] { return [...apps].sort((left, right) => left.name.localeCompare(right.name)); } -/** One action of a provider's app, as far as consent is concerned: its name and the scopes it requests. */ +/** + * One action of a provider's app, as far as consent is concerned: its name and the scopes it requests. + * + * A LIST OF THESE IS THE SET AN APPROVAL COVERS AND NOTHING WIDER — `consentActionsFrom` in + * `grants.ts` builds it through `coveredByApproval`, and an action needing no authorization is never + * in it. So the "N" in "approving this approves all N" below is `actions.length` because, and only + * because, every entry is something approving genuinely approves. + */ export type ConsentAction = { name: string; scopes: readonly string[] }; export type ConsentCopy = { diff --git a/app/src/lib/plugins/grants.ts b/app/src/lib/plugins/grants.ts index 63456ab91..da331695c 100644 --- a/app/src/lib/plugins/grants.ts +++ b/app/src/lib/plugins/grants.ts @@ -9,7 +9,17 @@ import type { ConsentAction } from "./enablement-notes"; * `app/` never imports from `server/`; the route's own docblock is the contract. */ export type GrantState = - | { action: string; state: "authorized"; scopes: string[] } + | { + action: string; + state: "authorized"; + scopes: string[]; + /** + * `held`: this account holds an authorization for it. `not-required`: the provider publishes + * no authorization for it at all, so it is usable and no approval ever touches it. See + * {@link coveredByApproval}, the one place this is turned into what an approval covers. + */ + authorization: "held" | "not-required"; + } | { action: string; state: "needs-consent"; scopes: string[] } | { action: string; state: "needs-operator"; missing: string[] } | { action: string; state: "unknown"; reason: string }; @@ -82,13 +92,39 @@ export function couldNotRead( ); } -/** The actions the consent copy reasons about: those that name a permission or could be asked for. */ +/** + * Whether approving at this app is what makes `grant` usable — THE ONE DEFINITION OF THE SET THE + * CONSENT COPY COUNTS. + * + * WHAT A PERSON CONSENTS TO IS THE AUTHORIZATION, so an action belongs here exactly when that + * authorization is what stands between it and being called: one somebody still has to approve + * (`needs-consent`), or one already approved and held (`authorized`, `held`). The "N" in "approving + * this approves all N" is the size of this set and of nothing else. + * + * AN ACTION THAT NEEDS NO AUTHORIZATION IS NOT IN IT. It is usable before anybody approves anything + * and stays usable if they refuse, so telling a person that approving "approves" it inflates what + * they are being asked to agree to by actions nothing ever withheld. That inflation is not + * hypothetical: when the server began reading an absent authorization block as `authorized` rather + * than `unknown`, every such action silently joined this count. So the decision is made HERE, on the + * field that says why an action is authorized, and never on `state` alone. + * + * `needs-operator` and `unknown` are not in it either: whether an approval would reach them cannot + * be read off the answer, so they are left out of the number rather than guessed into it. + */ +export function coveredByApproval( + grant: GrantState, +): grant is Extract { + if (grant.state === "needs-consent") return true; + return grant.state === "authorized" && grant.authorization === "held"; +} + +/** The actions the consent copy reasons about: exactly those {@link coveredByApproval} admits. */ export function consentActionsFrom( grants: readonly GrantState[], ): ConsentAction[] { const actions: ConsentAction[] = []; for (const grant of grants) { - if (grant.state === "authorized" || grant.state === "needs-consent") { + if (coveredByApproval(grant)) { actions.push({ name: grant.action, scopes: grant.scopes }); } } @@ -98,11 +134,18 @@ export function consentActionsFrom( export type GrantGroup = { /** The permissions every action in the group requires; empty for the whole-app and no-permission groups. */ scopes: string[]; - /** "whole-app" when no action in the app names a permission; "none" for actions that name none. */ + /** + * "whole-app" when no action in the app names a permission, holding every action one approval + * covers; "none" for actions that name none — always including those that need no authorization. + */ kind: "scoped" | "whole-app" | "none"; grants: GrantState[]; }; +function isNotRequired(grant: GrantState): boolean { + return grant.state === "authorized" && grant.authorization === "not-required"; +} + function scopesOf(grant: GrantState): string[] { return grant.state === "authorized" || grant.state === "needs-consent" ? [...new Set(grant.scopes)].sort() @@ -133,9 +176,19 @@ export function groupGrants(grants: readonly GrantState[]): GrantGroup[] { left.action.localeCompare(right.action), ); if (!anyScoped) { - return ordered.length > 0 - ? [{ scopes: [], kind: "whole-app", grants: ordered }] - : []; + // "Everything in this app, approved together" is a claim about what one approval covers, so an + // action that needs no authorization is drawn outside it — the same set the consent copy's + // count leaves out (see {@link coveredByApproval}), so the heading and the number agree. + const needsNone = ordered.filter(isNotRequired); + const together = ordered.filter((grant) => !isNotRequired(grant)); + const groups: GrantGroup[] = []; + if (together.length > 0) { + groups.push({ scopes: [], kind: "whole-app", grants: together }); + } + if (needsNone.length > 0) { + groups.push({ scopes: [], kind: "none", grants: needsNone }); + } + return groups; } const groups = new Map(); for (const grant of ordered) { diff --git a/app/tests/arcade-consent-copy.test.tsx b/app/tests/arcade-consent-copy.test.tsx index bb0c4b036..6d34395d5 100644 --- a/app/tests/arcade-consent-copy.test.tsx +++ b/app/tests/arcade-consent-copy.test.tsx @@ -4,6 +4,11 @@ import { consentCopy, type ConsentAction, } from "@/lib/plugins/enablement-notes"; +import { + consentActionsFrom, + type GrantState, + groupGrants, +} from "@/lib/plugins/grants"; /** * The copy shown before redirecting somebody to a consent screen (spec D30). The action list is @@ -17,9 +22,15 @@ type FixtureTool = { requirements?: { authorization?: { oauth2?: { scopes?: string[] } } }; }; +/** + * The fixture rows an approval covers, as `ConsentAction`s. A row publishing no authorization block + * (`Apollo`) is NOT one: nothing withholds it, so no approval approves it — the same line + * `coveredByApproval` draws over the grants answer. + */ function fromFixture(toolkit: string): ConsentAction[] { return (Object.values(fixture.tools) as FixtureTool[]) .filter((tool) => tool.toolkit.name === toolkit) + .filter((tool) => tool.requirements?.authorization != null) .map((tool) => ({ name: tool.qualified_name, scopes: tool.requirements?.authorization?.oauth2?.scopes ?? [], @@ -59,10 +70,11 @@ test("scoped, from the fixture: a lone scope set lists no sibling", () => { }); test("unscoped: all-or-nothing, with the count derived", () => { - const apollo = fromFixture("Apollo"); - expect(consentCopy(apollo[0]!.name, apollo).sentence).toContain( - "approving this approves its single action", - ); + // Apollo publishes no authorization at all, so there is nothing of it for an approval to cover. + expect(fromFixture("Apollo")).toEqual([]); + expect( + consentCopy("Github.Only", [{ name: "Github.Only", scopes: [] }]).sentence, + ).toContain("approving this approves its single action"); const forty3 = Array.from({ length: 43 }, (_, i) => ({ name: `Github.Action${i}`, scopes: [], @@ -100,3 +112,86 @@ test("no copy promises confinement", () => { ); } }); + +/** + * WHAT "N" MEANS in "approving this approves all N": the actions an approval genuinely covers — + * those still waiting on one and those whose authorization is held — and never an action that needs + * no authorization, which nothing withheld and no approval grants. + * + * Pinned against the COMPOSITION of the grants answer rather than a number: for every mix of + * covered and no-auth actions, N is the covered count read off the fixture by its `authorization` + * field, and adding no-auth actions never moves it. Counting the no-auth actions in — the drift the + * server's reading of an absent authorization block once caused silently — reddens every row with + * one or more of them. + */ +test("N counts what an approval covers, and no action that needs no authorization", () => { + for (const covered of [2, 3, 5]) { + for (const needsNone of [0, 1, 4]) { + const grants: GrantState[] = [ + ...Array.from( + { length: covered }, + (_, i): GrantState => + i % 2 === 0 + ? { + action: `Tk.Covered${i}`, + state: "needs-consent", + scopes: [], + } + : { + action: `Tk.Covered${i}`, + state: "authorized", + scopes: [], + authorization: "held", + }, + ), + ...Array.from( + { length: needsNone }, + (_, i): GrantState => ({ + action: `Tk.Free${i}`, + state: "authorized", + scopes: [], + authorization: "not-required", + }), + ), + { action: "Tk.Blocked", state: "needs-operator", missing: ["KEY"] }, + ]; + const expected = grants.filter( + (grant) => + grant.state === "needs-consent" || + (grant.state === "authorized" && grant.authorization === "held"), + ).length; + const actions = consentActionsFrom(grants); + expect(actions.map((action) => action.name).sort()).toEqual( + Array.from({ length: covered }, (_, i) => `Tk.Covered${i}`).sort(), + ); + expect(consentCopy("Tk.Covered0", actions).sentence).toContain( + `approving this approves all ${expected}.`, + ); + } + } +}); + +test("the whole-app group holds the same set the count does, and draws no-auth actions apart", () => { + const grants: GrantState[] = [ + { action: "Tk.A", state: "needs-consent", scopes: [] }, + { action: "Tk.B", state: "authorized", scopes: [], authorization: "held" }, + { + action: "Tk.Free", + state: "authorized", + scopes: [], + authorization: "not-required", + }, + ]; + const groups = groupGrants(grants); + const together = groups.find((group) => group.kind === "whole-app"); + expect(together?.grants.map((grant) => grant.action).sort()).toEqual( + consentActionsFrom(grants) + .map((action) => action.name) + .sort(), + ); + expect( + groups + .find((group) => group.kind === "none") + ?.grants.map((grant) => grant.action), + ).toEqual(["Tk.Free"]); +}); diff --git a/app/tests/connected-account-grants.test.tsx b/app/tests/connected-account-grants.test.tsx index 14a31d0b1..77c5b7253 100644 --- a/app/tests/connected-account-grants.test.tsx +++ b/app/tests/connected-account-grants.test.tsx @@ -237,11 +237,13 @@ const GMAIL: GrantState[] = [ action: "Gmail.ListEmails", state: "authorized", scopes: ["gmail.readonly"], + authorization: "held", }, { action: "Gmail.ListThreads", state: "authorized", scopes: ["gmail.readonly"], + authorization: "held", }, { action: "Gmail.SendEmail", state: "needs-consent", scopes: ["gmail.send"] }, { @@ -324,6 +326,7 @@ test("unknown offers Retry and never a speculative Authorize", async () => { action: "Gmail.ListEmails", state: "authorized", scopes: ["gmail.readonly"], + authorization: "held", }, ], }); @@ -385,9 +388,19 @@ test("an all-or-nothing app renders as ONE group containing everything", async ( provider: "arcade", connected: true, grants: [ - { action: "Github.A", state: "authorized", scopes: [] }, + { + action: "Github.A", + state: "authorized", + scopes: [], + authorization: "held", + }, { action: "Github.B", state: "needs-consent", scopes: [] }, - { action: "Github.C", state: "authorized", scopes: [] }, + { + action: "Github.C", + state: "authorized", + scopes: [], + authorization: "held", + }, ], }); const view = renderScreen(); @@ -406,8 +419,18 @@ test("authorized actions are listed first, and the unauthorized ones follow with connected: true, grants: [ { action: "Github.B", state: "needs-consent", scopes: [] }, - { action: "Github.C", state: "authorized", scopes: [] }, - { action: "Github.A", state: "authorized", scopes: [] }, + { + action: "Github.C", + state: "authorized", + scopes: [], + authorization: "held", + }, + { + action: "Github.A", + state: "authorized", + scopes: [], + authorization: "held", + }, { action: "Github.D", state: "unknown", reason: "Try again." }, ], }); @@ -429,6 +452,7 @@ test("past the first ten unauthorized actions the rest wait behind the search", action: "Gmail.ListEmails", state: "authorized", scopes: ["gmail.readonly"], + authorization: "held", }, ...Array.from({ length: 12 }, (_, index): GrantState => { const name = String.fromCharCode(97 + index); @@ -618,6 +642,45 @@ test("pressing Authorize renders consentCopy's sentence and redirects nowhere ye ); }); +test("on screen, the N an approval approves leaves out every action that needs no authorization", async () => { + // An all-unscoped app where two actions wait on the approval, one is held, and three need none. + const grants: GrantState[] = [ + { action: "Tk.A", state: "needs-consent", scopes: [] }, + { action: "Tk.B", state: "needs-consent", scopes: [] }, + { action: "Tk.C", state: "authorized", scopes: [], authorization: "held" }, + ...["Tk.X", "Tk.Y", "Tk.Z"].map( + (action): GrantState => ({ + action, + state: "authorized", + scopes: [], + authorization: "not-required", + }), + ), + ]; + const covered = grants.filter( + (grant) => + grant.state === "needs-consent" || + (grant.state === "authorized" && grant.authorization === "held"), + ).length; + // The fixture only discriminates if counting everything would give a different number. + expect(covered).not.toBe(grants.length); + install({ provider: "arcade", connected: true, grants }); + const view = renderScreen(); + await waitFor(() => row(view, "Tk.A")); + fireEvent.click(row(view, "Tk.A").querySelector("button") as HTMLElement); + const panel = await view.findByRole("group", { + name: /Before you authorize/, + }); + expect(panel.textContent).toContain( + `approving this approves all ${covered}.`, + ); + expect(row(view, "Tk.X").textContent).toContain("Needs no authorization."); + const together = view.container.querySelector( + '[data-grant-group="whole-app"]', + ); + expect(together?.querySelectorAll("[data-action]").length).toBe(covered); +}); + test("the consent copy never promises that only the named action becomes usable", async () => { install({ provider: "arcade", diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 1811f7605..cd9408531 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -1060,9 +1060,19 @@ function grantStateOf(item: unknown): GrantState[] { // NO AUTHORIZATION BLOCK IS THE VENDOR SAYING THIS ACTION NEEDS NONE, with every secret it does // need already read as set above — so it is usable, and `authorized` with no scopes is the state // that says so. See {@link authorizationRequirementOf} for why this is the one reading. + // `not-required`, NEVER `held`: nobody approved it and no approval reaches it, so the consent + // copy must not count it among what an approval approves. See {@link GrantState}. const read = authorizationRequirementOf(item); - if (read.kind === "none") - return [{ action, state: "authorized", scopes: [] }]; + if (read.kind === "none") { + return [ + { + action, + state: "authorized", + scopes: [], + authorization: "not-required", + }, + ]; + } if (read.kind === "unreadable") { return [ { @@ -1083,7 +1093,7 @@ function grantStateOf(item: unknown): GrantState[] { const tokenStatus = textOf(authorization.token_status); const status = textOf(authorization.status); if (tokenStatus === "completed" || (!tokenStatus && status === "active")) { - return [{ action, state: "authorized", scopes }]; + return [{ action, state: "authorized", scopes, authorization: "held" }]; } if ( tokenStatus === "not_started" || diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts index e86c8e4ac..681f0812f 100644 --- a/server/src/plugins/broker.ts +++ b/server/src/plugins/broker.ts @@ -394,9 +394,22 @@ export type AuthConfigOutcome = "created" | "standing" | "not-needed"; * when it is created, so every action in a toolkit shares one state and one scope list; N identical * entries is the honest rendering of a toolkit-wide grant, not a stub standing in for a missing * answer. + * + * `authorized` SAYS WHY THE ACTION IS USABLE, BECAUSE TWO DIFFERENT FACTS ANSWER IT. `held` is an + * authorization this account holds — a consent somebody gave, which a later consent screen is + * talking about. `not-required` is the vendor publishing no authorization for the action at all + * (Arcade's `Apollo`): it is usable, and no approval ever withheld it or will ever grant it. Both + * are `authorized` because both can be called; they are told apart because the consent copy's "approving this + * approves all N" counts only the first, and an action nobody has to approve is not something an + * approval covers. REQUIRED, not optional: an absent field would quietly read as one of the two. */ export type GrantState = - | { action: string; state: "authorized"; scopes: string[] } + | { + action: string; + state: "authorized"; + scopes: string[]; + authorization: "held" | "not-required"; + } | { action: string; state: "needs-consent"; scopes: string[] } | { action: string; state: "needs-operator"; missing: string[] } | { action: string; state: "unknown"; reason: string }; diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index f9ed30b47..d109ea4cd 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -3119,12 +3119,19 @@ export function buildComposioClient( limit: LISTING_LIMIT, }); // A fresh array per entry: one shared list would let a caller mutating its first grant's - // scopes rewrite every other grant in the answer. - return published.map((action) => ({ - action: action.slug, - state, - scopes: [...scopes], - })); + // scopes rewrite every other grant in the answer. An authorized entry is always `held`: it is + // authorized only because this person holds an account, which is a consent they gave. + return published.map( + (action): GrantState => + state === "authorized" + ? { + action: action.slug, + state, + scopes: [...scopes], + authorization: "held", + } + : { action: action.slug, state, scopes: [...scopes] }, + ); }, /** * The catalogue, from memory where this process asked for it less than ten minutes ago. diff --git a/server/tests/arcade-adapter-status.test.ts b/server/tests/arcade-adapter-status.test.ts index 7d0f94359..1a44abf19 100644 --- a/server/tests/arcade-adapter-status.test.ts +++ b/server/tests/arcade-adapter-status.test.ts @@ -113,6 +113,7 @@ describe("grantsFor maps a toolkit listing to this deployment's vocabulary", () action: "Github.SetStarred", state: "authorized", scopes: ["repo", "user"], + authorization: "held", }, { action: "Github.ListStargazers", @@ -187,9 +188,24 @@ describe("an action that publishes no authorization needs none, on every screen" expect(apps.map((app) => app.connection)).toEqual([{ kind: "no-auth" }]); expect(await broker.grantsFor({ toolkit: "Github", account })).toEqual([ - { action: "Apollo.EnrichOrganization", state: "authorized", scopes: [] }, - { action: "Apollo.SearchPeople", state: "authorized", scopes: [] }, - { action: "Apollo.SearchOrganizations", state: "authorized", scopes: [] }, + { + action: "Apollo.EnrichOrganization", + state: "authorized", + scopes: [], + authorization: "not-required", + }, + { + action: "Apollo.SearchPeople", + state: "authorized", + scopes: [], + authorization: "not-required", + }, + { + action: "Apollo.SearchOrganizations", + state: "authorized", + scopes: [], + authorization: "not-required", + }, ]); }); diff --git a/server/tests/arcade-empty-vendor-structures-engine.test.ts b/server/tests/arcade-empty-vendor-structures-engine.test.ts index e62fb093e..10a650e3b 100644 --- a/server/tests/arcade-empty-vendor-structures-engine.test.ts +++ b/server/tests/arcade-empty-vendor-structures-engine.test.ts @@ -178,7 +178,12 @@ describe("a secrets requirement that is not a list is unreadable, not satisfied" const { broker } = createArcadeClient("never-dialled"); expect(await broker.grantsFor({ account, toolkit: "Gmail" })).toEqual([ - { action: "Gmail.Send", state: "authorized", scopes: [] }, + { + action: "Gmail.Send", + state: "authorized", + scopes: [], + authorization: "held", + }, ]); }); diff --git a/server/tests/arcade-grants-route.test.ts b/server/tests/arcade-grants-route.test.ts index fa72b67fd..53f07999a 100644 --- a/server/tests/arcade-grants-route.test.ts +++ b/server/tests/arcade-grants-route.test.ts @@ -187,7 +187,12 @@ describe("whose identity is asked about", () => { test("a personal app asks upstream as the viewer", async () => { const n = await run("personal"); answer = async () => [ - { action: "Gmail.ListEmails", state: "authorized", scopes: ["a"] }, + { + action: "Gmail.ListEmails", + state: "authorized", + scopes: ["a"], + authorization: "held", + }, ]; const response = await grants(n.viewer, "user", n.id); expect(response.status).toBe(200); diff --git a/server/tests/composio-grants-for.test.ts b/server/tests/composio-grants-for.test.ts index e741782c7..ebf45239f 100644 --- a/server/tests/composio-grants-for.test.ts +++ b/server/tests/composio-grants-for.test.ts @@ -218,16 +218,19 @@ describe("Composio's grantsFor", () => { action: "GMAIL_FETCH_EMAILS", state: "authorized", scopes: ["gmail.readonly", "gmail.send"], + authorization: "held", }, { action: "GMAIL_SEND_EMAIL", state: "authorized", scopes: ["gmail.readonly", "gmail.send"], + authorization: "held", }, { action: "GMAIL_CREATE_LABEL", state: "authorized", scopes: ["gmail.readonly", "gmail.send"], + authorization: "held", }, ]); // The sameness is the claim, asserted on its own so that a per-action answer invented here @@ -416,6 +419,7 @@ describe("Composio's grantsFor", () => { action: "SLACK_SEND_MESSAGE", state: "authorized", scopes: ["slack.chat.write"], + authorization: "held", }, ]); }); @@ -458,7 +462,12 @@ describe("Composio's grantsFor", () => { expect( await broker.grantsFor({ toolkit: "firecrawl", account: ACCOUNT }), ).toEqual([ - { action: "FIRECRAWL_SCRAPE", state: "authorized", scopes: [] }, + { + action: "FIRECRAWL_SCRAPE", + state: "authorized", + scopes: [], + authorization: "held", + }, ]); }); }); From d735179efbb45bedfd78f3b70bc48d46fde07dea Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 13:02:47 +0200 Subject: [PATCH 205/232] fix(db): name the journal the check read in the refusal's repair SQL The out-of-reach refusal told operators to `insert into` / `update` `drizzle.__drizzle_migrations` by literal, while the reach check reads whatever MIGRATIONS_JOURNAL (or an applyMigrations caller) names. Under a non-default journal the message sent the operator to repair a table that was not the one refusing them. outOfReachRefusal now takes a required `journal: MigrationsJournal` and renders it schema-qualified and quoted in both repair branches (skipped migrations, NULL created_at) and in its opening line. Direction: the MigrationsJournal TYPE moves to migration-reach.ts (the leaf), the MIGRATIONS_JOURNAL VALUE stays in apply-migrations.ts, which re-exports the type and passes the value in. No cycle; migration-reach still imports nothing from apply-migrations. No default on the parameter, so no second spelling of the name can exist. Also drops the remaining literal spellings that asserted a name the code no longer guarantees: three doc comments in migration-reach.ts, the "same journal the CLI does" claim in scripts/migrate.ts, and the NULL case's raw insert in the integration test. Call sites of outOfReachRefusal (signature changed): - server/src/db/apply-migrations.ts applyMigrations (passes `journal`) - server/tests/migration-reach.test.ts: 2 existing + 3 new cases Co-Authored-By: Claude Opus 5.5 --- server/scripts/migrate.ts | 6 +- server/src/db/apply-migrations.ts | 13 ++-- server/src/db/migration-reach.ts | 51 +++++++++++++-- .../tests/migration-reach.integration.test.ts | 9 ++- server/tests/migration-reach.test.ts | 65 +++++++++++++++++++ 5 files changed, 129 insertions(+), 15 deletions(-) diff --git a/server/scripts/migrate.ts b/server/scripts/migrate.ts index 0420f2597..411d1d8ad 100644 --- a/server/scripts/migrate.ts +++ b/server/scripts/migrate.ts @@ -9,8 +9,10 @@ * * The migrator underneath it is part of `drizzle-orm`, which is a runtime dependency because the * server imports it anyway. It needs a connection and the folder of SQL files, both of which are in - * the image, and it keeps the same `drizzle.__drizzle_migrations` journal the CLI does, so the two - * are interchangeable and a database migrated by either is migrated. + * the image, and it keeps its journal where `MIGRATIONS_JOURNAL` in `src/db/apply-migrations` says. + * That is drizzle's default table, the one the CLI uses when `drizzle.config.ts` names none, so a + * database migrated by the CLI before this script existed is read here without translation, and + * the two were interchangeable — for exactly as long as those two names stay in step. * * It is now what every path runs — the image, the Helm Job, the compose stack, CI, and `db:migrate` * — because the two stopped being interchangeable in the direction that matters: only this one diff --git a/server/src/db/apply-migrations.ts b/server/src/db/apply-migrations.ts index 0bcab621e..5e32a61c7 100644 --- a/server/src/db/apply-migrations.ts +++ b/server/src/db/apply-migrations.ts @@ -14,6 +14,7 @@ import { migrate } from "drizzle-orm/postgres-js/migrator"; import type { Sql, TransactionSql } from "postgres"; import { type AppliedMigration, + type MigrationsJournal, migrationsOutOfReach, outOfReachRefusal, readMigrationFolder, @@ -27,11 +28,13 @@ import { * different tables the check would read an absent table, see nothing applied, and wave every * migration through — the refusal silently switched off. So both are handed this one object, and * nothing else in the server spells the name. + * + * The same object reaches the refusal's repair SQL (`outOfReachRefusal`), so the table an operator + * is told to fix is the table the check read. The type is declared in `./migration-reach` and the + * value here: that module is the dependency, not the dependent, and importing this value into it + * would close a cycle. */ -export type MigrationsJournal = { - migrationsSchema: string; - migrationsTable: string; -}; +export type { MigrationsJournal }; export const MIGRATIONS_JOURNAL: MigrationsJournal = { migrationsSchema: "drizzle", @@ -122,7 +125,7 @@ export async function applyMigrations( const outOfReach = migrationsOutOfReach(folder, applied); if (outOfReach.length > 0) { throw new MigrationsOutOfReachError( - outOfReachRefusal(outOfReach, folder, applied), + outOfReachRefusal(outOfReach, folder, applied, journal), ); } diff --git a/server/src/db/migration-reach.ts b/server/src/db/migration-reach.ts index 28cbe70c9..ff813cbe5 100644 --- a/server/src/db/migration-reach.ts +++ b/server/src/db/migration-reach.ts @@ -1,8 +1,8 @@ /** * Whether every migration in the folder can still be reached by the migrator, on THIS database. * - * WHAT THE MIGRATOR ACTUALLY DOES. `PgDialect.migrate` reads one row of - * `drizzle.__drizzle_migrations` — `order by created_at desc limit 1` — and then runs a migration + * WHAT THE MIGRATOR ACTUALLY DOES. `PgDialect.migrate` reads one row of its journal table (see + * `MigrationsJournal`) — `order by created_at desc limit 1` — and then runs a migration * if, and only if, its journal `when` is greater than `Number(created_at)` of that row. That row is * not always the newest stamp: Postgres sorts NULLs FIRST in a descending order, so a single row * with a NULL `created_at` is the row it reads, and `Number(null)` is 0. See `migratorHighWaterMark`. It never looks at hashes, never looks at tags, and never notices that a @@ -42,14 +42,14 @@ export type FolderMigration = { * * Computed exactly as `drizzle-orm/migrator`'s `readMigrationFiles` computes it — the whole file * as read, before any statement splitting — because this is matched against the hashes that the - * migrator itself wrote into `drizzle.__drizzle_migrations`. A different recipe here would make + * migrator itself wrote into its journal table. A different recipe here would make * every applied migration look unapplied. */ hash: string; }; /** - * One row of `drizzle.__drizzle_migrations`. + * One row of the migrator's journal table (see `MigrationsJournal`). * * `createdAt` is nullable because the column is: drizzle creates it as a bare `bigint`, and a NULL * there is not noise to be defaulted away — it is the row the migrator compares against. A reader @@ -57,6 +57,36 @@ export type FolderMigration = { */ export type AppliedMigration = { hash: string; createdAt: number | null }; +/** + * Where the migrator keeps its bookkeeping: the table it reads its high-water mark from and records + * each applied migration in. + * + * The SHAPE lives here, in the module with no database in it, and the VALUE does not: + * `MIGRATIONS_JOURNAL` in `./apply-migrations` is the one place the name is chosen, and it is handed + * to everything that needs it — the reach check's read, `migrate()`, and `outOfReachRefusal`'s repair + * instructions. The value cannot move here without this module deciding deployment configuration, + * and it cannot be imported from there without a cycle (`./apply-migrations` imports this module), + * so it is passed in. Nothing in this file may spell the table's name. + */ +export type MigrationsJournal = { + migrationsSchema: string; + migrationsTable: string; +}; + +/** + * The journal table as an operator would type it into `psql`: schema-qualified, each part quoted. + * + * Quoted always rather than when needed, because "when needed" is a reserved-word list this file + * has no business keeping; a quoted lower-case identifier means exactly what the bare one does. + */ +function journalTable({ + migrationsSchema, + migrationsTable, +}: MigrationsJournal): string { + const quote = (identifier: string) => `"${identifier.replaceAll('"', '""')}"`; + return `${quote(migrationsSchema)}.${quote(migrationsTable)}`; +} + type Journal = { entries: { idx: number; tag: string; when: number }[] }; /** @@ -145,12 +175,19 @@ export function migrationsOutOfReach( * It names the mark and what set it, every migration that cannot be reached, and the two ways out, * because the one thing an operator cannot work out from a stack trace is that the database is * *ahead* of the folder rather than behind it. + * + * `journal` is required, not defaulted: the repair instructions are SQL an operator will paste, and + * they must name the table the check actually read. A default here would be a second spelling of the + * name, and the first configuration that moved the journal would make this message send someone to + * repair a table that is not the one refusing them. */ export function outOfReachRefusal( outOfReach: readonly FolderMigration[], folder: readonly FolderMigration[], applied: readonly AppliedMigration[], + journal: MigrationsJournal, ): string { + const table = journalTable(journal); const highWaterMark = migratorHighWaterMark(applied) ?? 0; const appliedHashes = new Set(applied.map((row) => row.hash)); const skipped = outOfReach.filter( @@ -178,7 +215,7 @@ export function outOfReachRefusal( ); return [ - `This database has already recorded a migration stamped ${highWaterMark} (${mark}), and the migrator only runs migrations stamped after the row it reads: the first one ordered by created_at descending.`, + `This database's migration journal, ${table}, has already recorded a migration stamped ${highWaterMark} (${mark}), and the migrator only runs migrations stamped after the row it reads: the first one ordered by created_at descending.`, ...(nulls === 0 ? [] : [ @@ -200,7 +237,7 @@ export function outOfReachRefusal( ? "Every applied migration is still present in this folder." : `${unknown} applied ${unknown === 1 ? "migration is" : "migrations are"} not in this folder at all, which is what a renumbered or rewritten migration history looks like from here: this database ran a journal that no longer exists.`, nulls > 0 - ? "Nothing has been applied. Give every row with a NULL created_at the journal `when` of the migration whose hash it records (`update drizzle.__drizzle_migrations set created_at = where hash = `), or delete a row that matches no migration, and run again." - : "Nothing has been applied. Recreate the database from empty if it is disposable (a development, preview or CI database usually is); otherwise apply the SQL above by hand and record each one with `insert into drizzle.__drizzle_migrations (hash, created_at) values (, )`, which leaves the high-water mark where it is.", + ? `Nothing has been applied. Give every row with a NULL created_at the journal \`when\` of the migration whose hash it records (\`update ${table} set created_at = where hash = \`), or delete a row that matches no migration, and run again.` + : `Nothing has been applied. Recreate the database from empty if it is disposable (a development, preview or CI database usually is); otherwise apply the SQL above by hand and record each one with \`insert into ${table} (hash, created_at) values (, )\`, which leaves the high-water mark where it is.`, ].join("\n"); } diff --git a/server/tests/migration-reach.integration.test.ts b/server/tests/migration-reach.integration.test.ts index 1c5ce547d..d9fe56317 100644 --- a/server/tests/migration-reach.integration.test.ts +++ b/server/tests/migration-reach.integration.test.ts @@ -227,6 +227,13 @@ describe("a journal kept somewhere other than the default", () => { expect(refusal).toBeInstanceOf(MigrationsOutOfReachError); expect(await exists(client, "from_main")).toBe(false); + // And the repair the refusal prescribes is SQL against the table it actually read. + const message = (refusal as Error).message; + expect(message).toContain( + 'insert into "c4_journal"."applied_migrations" (hash, created_at)', + ); + expect(message).not.toContain(MIGRATIONS_JOURNAL.migrationsTable); + // And the bookkeeping is where it was configured to be, and nowhere else. const [row] = await client<{ count: string }[]>` select count(*)::text as count @@ -471,7 +478,7 @@ describe("a database whose migrations table holds a NULL created_at", () => { const folder = await folderOf([first, asShipped]); await applyMigrations(client, folder); await client` - insert into drizzle.__drizzle_migrations (hash, created_at) + insert into ${client(MIGRATIONS_JOURNAL.migrationsSchema)}.${client(MIGRATIONS_JOURNAL.migrationsTable)} (hash, created_at) values ('hand-written', null) `; diff --git a/server/tests/migration-reach.test.ts b/server/tests/migration-reach.test.ts index 3f0856e8c..349589b03 100644 --- a/server/tests/migration-reach.test.ts +++ b/server/tests/migration-reach.test.ts @@ -6,11 +6,13 @@ import { join } from "node:path"; import { type AppliedMigration, type FolderMigration, + type MigrationsJournal, migrationsOutOfReach, migratorHighWaterMark, outOfReachRefusal, readMigrationFolder, } from "../src/db/migration-reach"; +import { MIGRATIONS_JOURNAL } from "../src/db/apply-migrations"; /** * The check that `migration-journal.test.ts` cannot make. @@ -197,6 +199,7 @@ describe("the row the migrator reads", () => { migrationsOutOfReach(folder, database), folder, database, + MIGRATIONS_JOURNAL, ); expect(text).toContain("1 applied row has a NULL created_at"); @@ -218,6 +221,7 @@ describe("the refusal", () => { migrationsOutOfReach(folder, database), folder, database, + MIGRATIONS_JOURNAL, ); test("names the migration that cannot be reached", () => { @@ -237,6 +241,67 @@ describe("the refusal", () => { }); }); +describe("the refusal, for a journal kept somewhere other than the default", () => { + /* + * The repair instructions are SQL an operator pastes into psql. They must name the table the + * check read — never a spelling of the default that the configuration no longer guarantees. + */ + const elsewhere: MigrationsJournal = { + migrationsSchema: "c4_journal", + migrationsTable: "applied_migrations", + }; + const folder = [ + migration("0000_first", 1000, "a"), + migration("0001_from_main", 2000, "main"), + migration("0002_branch_renumbered", 4000, "renumbered"), + ]; + + test("tells the operator to record a skipped migration in that table", () => { + const database = [applied("a", 1000), applied("branch-as-shipped", 3000)]; + const text = outOfReachRefusal( + migrationsOutOfReach(folder, database), + folder, + database, + elsewhere, + ); + + expect(text).toContain( + 'insert into "c4_journal"."applied_migrations" (hash, created_at)', + ); + expect(text).not.toContain("__drizzle_migrations"); + }); + + test("tells the operator to repair a NULL created_at in that table", () => { + const database = [ + applied("a", 1000), + applied("main", 2000), + applied("hand-written", null), + ]; + const text = outOfReachRefusal( + migrationsOutOfReach(folder, database), + folder, + database, + elsewhere, + ); + + expect(text).toContain("1 applied row has a NULL created_at"); + expect(text).toContain('update "c4_journal"."applied_migrations" set'); + expect(text).not.toContain("__drizzle_migrations"); + }); + + test("quotes a name that would otherwise need it", () => { + const database = [applied("a", 1000), applied("branch-as-shipped", 3000)]; + const text = outOfReachRefusal( + migrationsOutOfReach(folder, database), + folder, + database, + { migrationsSchema: "Ops", migrationsTable: 'odd"name' }, + ); + + expect(text).toContain('insert into "Ops"."odd""name" (hash, created_at)'); + }); +}); + describe("reading a migration folder", () => { test("hashes the whole file the way the migrator does", async () => { const directory = await mkdtemp(join(tmpdir(), "migration-reach-")); From 9784073993f6f982737fd6abca36cf05650e30a1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 13:04:02 +0200 Subject: [PATCH 206/232] test(composio): make the adapter suite's vendor doubles honour the query they are handed (A8a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `server/tests/composio-adapter.test.ts` built every listing double as `list: async () => ({ items: [...] })`: `connectedAccounts.list` answered `ca_1` to any person, any app and any status set, `authConfigs.list` answered one app's configs to a question about any app or none, and `tools.list` likewise. Every account fixture also spelled `userId` and `vendorUserId` both as `user_1`, so even the four tests that pinned the query's exact shape could not tell the local id from the vendor's. Measured at 944e5266, five production plants passed 245/245: revoke, isConnected, accountName, authorize and connectWithFields each asking under `account.userId`; and connectWithFields reading another app's configs passed too. Now: - `VENDOR_USER = "composio_user_1"` is the vendor id of every person fixture (79 literals), distinct from the local `user_1`. - `accountListing` / `heldAccounts` answer as the vendor does: a present `userIds`, `toolkitSlugs`, `statuses`, `authConfigIds` or `accountType: "ALL"` narrows, an absent one does not, pages are served by `page_`, and the queries come back for assertion by name. Every fixture carries DECOYS: somebody else's account, one under the LOCAL id, another app's, a REVOKED tombstone, and (for withdrawal) this person's account on an operator's hand-made config. - `configListing` / `configsOf` serve configs only to the `toolkit` asked, plus an `ac_0_slack_ours` decoy that sorts first and is served to slack or to a listing naming no app. `ourGmailConfig` is one. - `actionsOf` does the same for `tools.list`. - ~60 sites converted: the five named (revoke x4, isConnected-no) and every revoke/isConnected/accountName/authorize/deleteAuthConfig/ ensureAuthConfig/connectWithFields double outside the row-shape and cursor-fault sections. New test: a person's accountName (only the deployment's was covered). PRE-EXISTING TESTS THAT WENT RED, all expectations written against the blind fixture (production sends `vendorUserId` correctly): 13 asserted `"user_1"` as the id on `link`, `create.user_id` or `userIds` — now `VENDOR_USER`. And "nobody with no account is answered no" went red because the default decoys included this person's account on an operator config, which isConnected rightly counts; it now uses NOT_THIS_PERSONS_GMAIL plus an EXPIRED row of theirs. WATCHED FAILING (plant in composio-adapter.ts, run, restore; src verified byte-identical after each). Base -> fixed: - revoke under account.userId: 0 -> 18 fail - isConnected under account.userId: 0 -> 3 - accountName under account.userId: 0 -> 1 - authorize link under account.userId: 0 -> 7 - connectWithFields under userId: 0 -> 2 - connectWithFields reads slack configs: 0 -> 6 - revoke reads slack configs: 1 -> 27 - authorize reads slack configs: 1 -> 10 - authConfigs.list with no toolkit: 2 -> 36 - account listing without userIds: 5 -> 23 - without toolkitSlugs: 3 -> 20 - without statuses: 4 -> 20 - without authConfigIds: 3 -> 17 - without accountType "ALL": (new) 3 - tools.list for another toolkit: 2 -> 4 CALL SITES: all helpers and VENDOR_USER are file-local to composio-adapter.test.ts. `server/src/` is untouched. assertion-shape.test.ts: only the line numbers of its two composio-adapter ALLOW entries moved (5067 -> 5412, 5115 -> 5458). Co-Authored-By: Claude Opus 5.5 --- server/tests/assertion-shape.test.ts | 4 +- server/tests/composio-adapter.test.ts | 1143 ++++++++++++++++++------- 2 files changed, 816 insertions(+), 331 deletions(-) diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 7b7df1424..83e687e1b 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -835,14 +835,14 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/composio-adapter.test.ts", - line: 5067, + line: 5412, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', }, { file: "server/tests/composio-adapter.test.ts", - line: 5115, + line: 5458, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"', diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts index 1b20d1711..19a7423ba 100644 --- a/server/tests/composio-adapter.test.ts +++ b/server/tests/composio-adapter.test.ts @@ -218,8 +218,210 @@ const BY_HAND_GMAIL = { status: "ENABLED", }; +/* + * THE VENDOR DOUBLES BELOW HONOUR THE QUERY THEY ARE HANDED, and that is half of what this file + * claims about every method that reads a listing. + * + * A double that hands back its rows whatever it was asked cannot tell a correct question from a + * wrong one. Every listing here used to be one: `connectedAccounts.list` answered `ca_1` to any + * person, any app and any set of statuses, and `authConfigs.list` answered gmail's config to a + * question about any app or about none. So the adapter asking about the WRONG PERSON, dropping a + * filter, or reading ANOTHER APP's configs passed every test that was not itself an assertion on + * the query's exact shape — and those were four tests, each of which pinned a query that happened + * to name `user_1` for both of the person's ids. `composio-grants-for.test.ts` and + * `arcade-adapter-connections.test.ts` closed the same blindness in their own files; this is that + * correction for the rest of the Composio adapter. + * + * So each listing double answers as the vendor does: a filter it is given narrows the rows, a + * filter it is NOT given does not — an account listing with no `userIds` is every person's, which + * is the defect at its worst — and every fixture carries rows the right question must leave out. + */ + +/** + * The person every fixture here acts for, in the vendor's spelling. + * + * DIFFERENT FROM THE LOCAL `user_1` ON PURPOSE. `AccountRef` carries both, and every listing, + * link and creation goes out under `vendorUserId`; with the two spelled alike a call made under the + * local id read exactly like the right one. + */ +const VENDOR_USER = "composio_user_1"; + +/** The vendor's account statuses, so a fixture naming one it does not publish is a type error. */ +type AccountStatus = + | "INITIALIZING" + | "INITIATED" + | "ACTIVE" + | "FAILED" + | "EXPIRED" + | "INACTIVE" + | "REVOKED"; + +/** + * A connected account as Composio holds it: the row it would send, and the facts its listing is + * filtered on, kept apart so the routing facts cannot reach the adapter as fields of the row. + * + * Every fact defaults to this file's ordinary case — {@link VENDOR_USER}'s ACTIVE, private gmail + * account on {@link OUR_GMAIL} — so a fixture spells only the fact it is about. `page` is the page + * of the listing the row sits on, for the tests about reading every page. + */ +type HeldAccount = { + row: unknown; + userId?: string; + toolkit?: string; + status?: AccountStatus; + authConfigId?: string; + shared?: boolean; + page?: number; +}; + +/** What the adapter asked an account listing, as the tests below assert it by name. */ +type AccountQuery = { + userIds?: string[]; + toolkitSlugs?: string[]; + statuses?: string[]; + accountType?: string; + authConfigIds?: string[]; + limit?: number; + cursor?: string; +}; + +/** + * Accounts no question about {@link VENDOR_USER}'s gmail may return, whichever method is asking. + * + * One per filter the adapter is responsible for: somebody else's; one filed under the LOCAL id, + * which is a real vendor identity the moment anybody is ever given it; another app's; and a + * tombstone, which neither the gate nor the withdrawal counts. A listing that drops a filter, or + * asks under the wrong id, hands one of these back — and every test below then answers about it. + */ +const NOT_THIS_PERSONS_GMAIL: HeldAccount[] = [ + { row: { id: "ca_somebody_else" }, userId: "composio_user_2" }, + { row: { id: "ca_under_the_local_id" }, userId: "user_1" }, + { row: { id: "ca_another_app" }, toolkit: "slack" }, + { row: { id: "ca_tombstone" }, status: "REVOKED" }, +]; + +/** + * And one the WITHDRAWAL may not reach although the gate counts it: this person's own account on a + * config an operator built by hand. `isConnected` asks of every config, so it is connected; `revoke` + * is scoped to configs this deployment made, so it is not this deployment's to delete. + */ +const ON_AN_OPERATORS_CONFIG: HeldAccount = { + row: { id: "ca_on_an_operators_config" }, + authConfigId: BY_HAND_GMAIL.id, +}; + +/** Every account a question about {@link VENDOR_USER}'s gmail grants by THIS deployment must leave out. */ +const DECOYS: HeldAccount[] = [ + ...NOT_THIS_PERSONS_GMAIL, + ON_AN_OPERATORS_CONFIG, +]; + +/** + * `connectedAccounts.list` as Composio answers it, with the queries it was asked. + * + * A filter that is present narrows; one that is absent does not, which is the vendor's own reading + * (`accountType` defaults to private accounts only). Pages are `page_`, and a cursor is offered + * while any matching row sits on a later page. + */ +function accountListing( + held: HeldAccount[], + decoys: HeldAccount[] = DECOYS, +): { list: (query: unknown) => Promise; asked: AccountQuery[] } { + const asked: AccountQuery[] = []; + const list = async (query: unknown) => { + const q = query as AccountQuery; + asked.push(q); + const matching = [...held, ...decoys].filter( + (account) => + (q.userIds === undefined || + q.userIds.includes(account.userId ?? VENDOR_USER)) && + (q.toolkitSlugs === undefined || + q.toolkitSlugs.includes(account.toolkit ?? "gmail")) && + (q.statuses === undefined || + q.statuses.includes(account.status ?? "ACTIVE")) && + (q.authConfigIds === undefined || + q.authConfigIds.includes(account.authConfigId ?? OUR_GMAIL.id)) && + (q.accountType === "ALL" || account.shared !== true), + ); + const page = q.cursor === undefined ? 1 : Number(q.cursor.slice(5)); + const later = matching.some((account) => (account.page ?? 1) > page); + return { + items: matching + .filter((account) => (account.page ?? 1) === page) + .map((account) => account.row), + nextCursor: later ? `page_${page + 1}` : null, + }; + }; + return { list, asked }; +} + +/** The ordinary case: these rows are {@link VENDOR_USER}'s ACTIVE gmail accounts on our config. */ +function heldAccounts(rows: unknown[]) { + return accountListing(rows.map((row) => ({ row }))).list; +} + +/** + * Another app's config of ours, which a question about any other app must leave out. + * + * ITS ID SORTS FIRST, so an adapter that read the project's whole config listing — no `toolkit` — + * or another app's would take this one wherever it picks the first of ours. + */ +const OUR_SLACK = { + id: "ac_0_slack_ours", + name: "Slack (OpenBot)", + status: "ENABLED", +}; + +/** + * `authConfigs.list` as Composio answers it: scoped to the `toolkit` asked about, and the whole + * project's configs — {@link OUR_SLACK} among them — to a listing that names none. + */ +function configListing(toolkit: string, rows: unknown[]) { + const asked: unknown[] = []; + const byToolkit: Record = { + slack: [OUR_SLACK], + [toolkit]: rows, + }; + const list = async (query: unknown) => { + asked.push(query); + const named = (query as { toolkit?: string }).toolkit; + return { + items: + named === undefined + ? Object.values(byToolkit).flat() + : (byToolkit[named] ?? []), + }; + }; + return { list, asked }; +} + +/** One app's configs, for a double that does not need the queries back. */ +function configsOf(toolkit: string, rows: unknown[]) { + return configListing(toolkit, rows).list; +} + +/** + * `tools.list` as Composio answers it: one app's actions to a listing naming that app, another + * app's to a listing naming that one, and every app's to a listing naming none. + */ +function actionsOf(toolkit: string, rows: unknown[]) { + const byToolkit: Record = { + slack: [{ slug: "SLACK_SEND_MESSAGE", toolkit: { slug: "slack" } }], + [toolkit]: rows, + }; + return async (query: unknown) => { + const named = (query as { toolkit_slug?: string }).toolkit_slug; + return { + items: + named === undefined + ? Object.values(byToolkit).flat() + : (byToolkit[named] ?? []), + }; + }; +} + /** That listing as a vendor double, since every withdrawing fixture below needs the same one. */ -const ourGmailConfig = async () => ({ items: [OUR_GMAIL] }); +const ourGmailConfig = configsOf("gmail", [OUR_GMAIL]); /** * The three sentences `authorize` can refuse with, told apart by the remedy each one prescribes. @@ -343,24 +545,23 @@ describe("the page size this file is written against", () => { describe("listing an app's actions", () => { test("the caller's page reaches the vendor, so the vendor's default never applies", async () => { const asked: unknown[] = []; + const gmailActions = actionsOf("gmail", [ + { + slug: "GMAIL_FETCH_EMAILS", + name: "Fetch emails", + description: "Fetch emails from Gmail.", + input_parameters: { type: "object", properties: {} }, + tags: ["readOnlyHint"], + version: "20260903_00", + toolkit: { slug: "gmail" }, + }, + ]); const { actions } = buildComposioClient( fakeVendor({ tools: { list: async (query: unknown) => { asked.push(query); - return { - items: [ - { - slug: "GMAIL_FETCH_EMAILS", - name: "Fetch emails", - description: "Fetch emails from Gmail.", - input_parameters: { type: "object", properties: {} }, - tags: ["readOnlyHint"], - version: "20260903_00", - toolkit: { slug: "gmail" }, - }, - ], - }; + return gmailActions(query); }, }, }), @@ -893,23 +1094,16 @@ describe("executing an action", () => { describe("beginning one person's connection", () => { test("the link carries the page this deployment sends them back to", async () => { const linked: unknown[] = []; - const listed: unknown[] = []; + const { list, asked: listed } = configListing("linear", [ + { + id: "ac_this_deployments", + name: "Linear (OpenBot)", + status: "ENABLED", + }, + ]); const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { - list: async (query: unknown) => { - listed.push(query); - return { - items: [ - { - id: "ac_this_deployments", - name: "Linear (OpenBot)", - status: "ENABLED", - }, - ], - }; - }, - }, + authConfigs: { list }, connectedAccounts: { link: async (...call: unknown[]) => { linked.push(call); @@ -920,7 +1114,11 @@ describe("beginning one person's connection", () => { ); const begun = await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: "https://openbot.test/settings/connected-accounts/composio-linear", @@ -934,7 +1132,7 @@ describe("beginning one person's connection", () => { ]); expect(linked).toEqual([ [ - "user_1", + VENDOR_USER, "ac_this_deployments", { callbackUrl: @@ -960,7 +1158,7 @@ describe("beginning one person's connection", () => { const linked: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [] }) }, + authConfigs: { list: configsOf("linear", []) }, connectedAccounts: { link: async (...call: unknown[]) => { linked.push(call); @@ -971,7 +1169,11 @@ describe("beginning one person's connection", () => { ); const refused = broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: "https://openbot.test/settings/connected-accounts/composio-linear", @@ -1020,7 +1222,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const linked: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: MIXED }) }, + authConfigs: { list: configsOf("linear", MIXED) }, connectedAccounts: { link: async (...call: unknown[]) => { linked.push(call); @@ -1031,7 +1233,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { ); await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: "https://openbot.test/settings/connected-accounts/x", }); @@ -1041,7 +1247,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { // the hand-made one is not a mistake a later call can correct. expect(linked).toEqual([ [ - "user_1", + VENDOR_USER, "ac_ours", { callbackUrl: "https://openbot.test/settings/connected-accounts/x" }, ], @@ -1053,7 +1259,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [BY_HAND] }), + list: configsOf("linear", [BY_HAND]), }, connectedAccounts: { link: async (...call: unknown[]) => { @@ -1065,7 +1271,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { ); const refused = broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: "https://openbot.test/settings/connected-accounts/x", }); @@ -1088,7 +1298,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { // Both of ours go — leaving one behind would leave live grants — and the hand-made // one stands. The listing arrives spare-first, so the order asserted below is the sort's // and not the vendor's. - list: async () => ({ items: MIXED }), + list: configsOf("linear", MIXED), delete: async (...call: unknown[]) => { deleted.push(call); }, @@ -1120,7 +1330,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: MIXED }), + list: configsOf("linear", MIXED), delete: async (id: string) => { if (id === "ac_ours_spare") { throw new Error("Composio refused that one."); @@ -1147,9 +1357,9 @@ describe("telling this deployment's auth configs from anybody else's", () => { * grants on it, same accounts connected against it; the one field that changed is the * only one this file writes and the only one it can recognise itself by. */ - list: async () => ({ - items: [{ id: "ac_ours", name: "Linear", status: "ENABLED" }], - }), + list: configsOf("linear", [ + { id: "ac_ours", name: "Linear", status: "ENABLED" }, + ]), delete: async (...call: unknown[]) => { deleted.push(call); }, @@ -1196,12 +1406,10 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_theirs", name: "Linear", status: "ENABLED" }, - { id: "ac_nameless", status: "ENABLED" }, - ], - }), + list: configsOf("linear", [ + { id: "ac_theirs", name: "Linear", status: "ENABLED" }, + { id: "ac_nameless", status: "ENABLED" }, + ]), delete: async (...call: unknown[]) => { deleted.push(call); }, @@ -1273,7 +1481,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [OURS, OURS, OURS_SPARE] }), + list: configsOf("linear", [OURS, OURS, OURS_SPARE]), delete: async (id: string) => { if (id === "ac_ours_spare") { throw new Error("Composio refused that one."); @@ -1385,9 +1593,10 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [{ id: "ac_nameless", status: "ENABLED" }, OURS], - }), + list: configsOf("linear", [ + { id: "ac_nameless", status: "ENABLED" }, + OURS, + ]), }, connectedAccounts: { link: async (...call: unknown[]) => { @@ -1399,13 +1608,17 @@ describe("telling this deployment's auth configs from anybody else's", () => { ); await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }); expect(linked).toEqual([ - ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], + [VENDOR_USER, "ac_ours", { callbackUrl: RETURN_URL }], ]); }); @@ -1420,7 +1633,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [{ id: "ac_nameless" }] }), + list: configsOf("linear", [{ id: "ac_nameless" }]), }, connectedAccounts: { link: async (...call: unknown[]) => { @@ -1433,7 +1646,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -1467,7 +1684,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [{ id: "ac_nameless" }] }), + list: configsOf("linear", [{ id: "ac_nameless" }]), create: async (...call: unknown[]) => { created.push(call); return CREATED; @@ -1508,7 +1725,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [] }), + list: configsOf("linear", []), delete: async (...call: unknown[]) => { deleted.push(call); }, @@ -1527,11 +1744,9 @@ describe("telling this deployment's auth configs from anybody else's", () => { authConfigs: { // Disabled configs are asked for, because this listing is what decides whether to create. // A listing that omitted them would find nothing and create the split it exists to stop. - list: async () => ({ - items: [ - { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, - ], - }), + list: configsOf("linear", [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ]), create: async (...call: unknown[]) => { created.push(call); }, @@ -1553,11 +1768,9 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, - ], - }), + list: configsOf("linear", [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ]), }, connectedAccounts: { link: async (...call: unknown[]) => { @@ -1569,7 +1782,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { ); const refused = broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: "https://openbot.test/settings/connected-accounts/x", }); @@ -1601,12 +1818,10 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_a", name: "Linear (OpenBot)", status: "DISABLED" }, - { id: "ac_b", name: "Linear (OpenBot)", status: "PENDING" }, - ], - }), + list: configsOf("linear", [ + { id: "ac_a", name: "Linear (OpenBot)", status: "DISABLED" }, + { id: "ac_b", name: "Linear (OpenBot)", status: "PENDING" }, + ]), }, connectedAccounts: { link: async (...call: unknown[]) => { @@ -1620,7 +1835,11 @@ describe("telling this deployment's auth configs from anybody else's", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -1641,7 +1860,7 @@ describe("telling this deployment's auth configs from anybody else's", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [BY_HAND] }), + list: configsOf("linear", [BY_HAND]), create: async (...call: unknown[]) => { created.push(call); return CREATED; @@ -1687,7 +1906,7 @@ describe("the config each kind of app is enabled with", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [] }), + list: configsOf("linear_mcp", []), create: async (...call: unknown[]) => { created.push(call); return CREATED; @@ -1726,7 +1945,7 @@ describe("the config each kind of app is enabled with", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [] }), + list: configsOf("perplexityai", []), create: async (...call: unknown[]) => { created.push(call); return CREATED; @@ -1794,7 +2013,7 @@ describe("the config each kind of app is enabled with", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [] }), + list: configsOf("docusign", []), }, }), ); @@ -1840,7 +2059,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }] }), + list: heldAccounts([{ id: "ca_1" }]), delete: async (...call: unknown[]) => { deleted.push(call); return WITHDRAWN; @@ -1851,7 +2070,11 @@ describe("withdrawing one person's grants", () => { expect( await broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); @@ -1863,7 +2086,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }] }), + list: heldAccounts([{ id: "ca_1" }]), // A 200 whose body says the account was not deleted. The flag went out, Composio read // the request and answered it — this is the vendor declining, not failing. delete: async () => ({ success: false }), @@ -1882,7 +2105,11 @@ describe("withdrawing one person's grants", () => { */ const refusal = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -1900,7 +2127,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }] }), + list: heldAccounts([{ id: "ca_1" }]), /* * `success` IS REQUIRED IN THE DECLARATION AND ABSENT ON THIS WIRE, which is the shape * the check has to survive rather than the one it is for. `@composio/client` parses the @@ -1914,7 +2141,11 @@ describe("withdrawing one person's grants", () => { const refusal = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -1948,7 +2179,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }] }), + list: heldAccounts([{ id: "ca_1" }]), delete: async () => "deleted", }, }), @@ -1956,7 +2187,11 @@ describe("withdrawing one person's grants", () => { const refusal = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -1970,22 +2205,22 @@ describe("withdrawing one person's grants", () => { }); test("the listing asks about every state a grant can be hiding in", async () => { - const asked: unknown[] = []; + // Nothing of this person's to withdraw — only the accounts every filter must leave out. + const { list, asked } = accountListing([]); const { broker } = buildComposioClient( fakeVendor({ authConfigs: { list: ourGmailConfig }, - connectedAccounts: { - list: async (query: unknown) => { - asked.push(query); - return { items: [] }; - }, - }, + connectedAccounts: { list }, }), ); expect( await broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(false); @@ -2006,7 +2241,7 @@ describe("withdrawing one person's grants", () => { */ expect(asked).toEqual([ { - userIds: ["user_1"], + userIds: [VENDOR_USER], toolkitSlugs: ["gmail"], statuses: [ "INITIALIZING", @@ -2024,21 +2259,22 @@ describe("withdrawing one person's grants", () => { }); test("being connected is a narrower question, and asked as one", async () => { - const asked: unknown[] = []; + // SHARED, so the `true` below is only reachable through `accountType: "ALL"`: the vendor's + // default is private accounts only, and a listing that dropped it would answer `false` here. + const { list, asked } = accountListing([ + { row: { id: "ca_1" }, shared: true }, + ]); const { broker } = buildComposioClient( - fakeVendor({ - connectedAccounts: { - list: async (query: unknown) => { - asked.push(query); - return { items: [{ id: "ca_1" }] }; - }, - }, - }), + fakeVendor({ connectedAccounts: { list } }), ); expect( await broker.isConnected({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); @@ -2047,7 +2283,7 @@ describe("withdrawing one person's grants", () => { // but `accountType: "ALL"` all the same, because a shared account is a connected account. expect(asked).toEqual([ { - userIds: ["user_1"], + userIds: [VENDOR_USER], toolkitSlugs: ["gmail"], statuses: ["ACTIVE"], accountType: "ALL", @@ -2057,10 +2293,18 @@ describe("withdrawing one person's grants", () => { }); test("nobody with no account for the app is answered no", async () => { + /* + * NOT AN EMPTY LISTING, WHICH ANSWERS `false` TO EVERY QUESTION, RIGHT OR WRONG. The vendor + * holds somebody else's gmail account, one filed under this person's LOCAL id, this person's + * account for another app, a revoked one and an expired one — and an adapter that asked about + * the wrong person, or dropped any filter, finds one of them and opens the gate. + */ + const { list, asked } = accountListing( + [{ row: { id: "ca_expired" }, status: "EXPIRED" }], + NOT_THIS_PERSONS_GMAIL, + ); const { broker } = buildComposioClient( - fakeVendor({ - connectedAccounts: { list: async () => ({ items: [] }) }, - }), + fakeVendor({ connectedAccounts: { list } }), () => 1_000_000, ); @@ -2072,10 +2316,16 @@ describe("withdrawing one person's grants", () => { */ expect( await broker.isConnected({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(false); + // And it was a question that was asked, rather than a `false` reached without one. + expect(asked.map((query) => query.userIds)).toEqual([[VENDOR_USER]]); }); /** @@ -2108,11 +2358,19 @@ describe("withdrawing one person's grants", () => { ); await broker.isConnected({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }); await broker.isConnected({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }); @@ -2125,9 +2383,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ - items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], - }), + list: heldAccounts([{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }]), delete: async (id: string) => { if (id === "ca_2") throw new Error("Composio refused that one."); deleted.push(id); @@ -2138,7 +2394,11 @@ describe("withdrawing one person's grants", () => { ); const refused = broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }); @@ -2153,7 +2413,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + list: heldAccounts([{ id: "ca_1" }, { id: "ca_2" }]), delete: async (id: string) => { if (id === "ca_2") throw new Error("Composio refused that one."); return WITHDRAWN; @@ -2171,7 +2431,11 @@ describe("withdrawing one person's grants", () => { */ await expect( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).rejects.toBeInstanceOf(BrokerRefusalError); @@ -2198,9 +2462,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ - items: [{ id: "ca_1" }, { id: null }, { id: "ca_3" }], - }), + list: heldAccounts([{ id: "ca_1" }, { id: null }, { id: "ca_3" }]), delete: async (id: string) => { deleted.push(id); return WITHDRAWN; @@ -2212,7 +2474,11 @@ describe("withdrawing one person's grants", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -2256,12 +2522,10 @@ describe("withdrawing one person's grants", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [{ id: "ac_gmail_nameless" }, OUR_GMAIL], - }), + list: configsOf("gmail", [{ id: "ac_gmail_nameless" }, OUR_GMAIL]), }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }] }), + list: heldAccounts([{ id: "ca_1" }]), delete: async (id: string) => { deleted.push(id); return WITHDRAWN; @@ -2273,7 +2537,11 @@ describe("withdrawing one person's grants", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -2324,18 +2592,20 @@ describe("withdrawing one person's grants", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [{ id: "ac_gmail_nameless" }, OUR_GMAIL], - }), + list: configsOf("gmail", [{ id: "ac_gmail_nameless" }, OUR_GMAIL]), }, - connectedAccounts: { list: async () => ({ items: [] }) }, + connectedAccounts: { list: heldAccounts([]) }, }), () => 1_000_000, ); const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -2365,14 +2635,18 @@ describe("withdrawing one person's grants", () => { test("an account the vendor described without an id still counts as connected", async () => { const { broker } = buildComposioClient( fakeVendor({ - connectedAccounts: { list: async () => ({ items: [{}] }) }, + connectedAccounts: { list: heldAccounts([{}]) }, }), () => 1_000_000, ); expect( await broker.isConnected({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); @@ -2397,20 +2671,19 @@ describe("withdrawing one person's grants", () => { * deleted everything it found answers this test's `true` just as confidently. */ test("the withdrawal asks only about accounts on configs this deployment made", async () => { - const scoped: unknown[] = []; const deleted: string[] = []; + // {@link DECOYS} carries this person's own gmail account on the operator's config, so an + // unscoped question hands it back and the delete below names it. + const { list, asked } = accountListing([{ row: { id: "ca_1" } }]); const { broker } = buildComposioClient( fakeVendor({ authConfigs: { // An operator's beside ours, which is the state the whole guard is about. Out of the // vendor's order, so a reader that took the first row would take the wrong one. - list: async () => ({ items: [BY_HAND_GMAIL, OUR_GMAIL] }), + list: configsOf("gmail", [BY_HAND_GMAIL, OUR_GMAIL]), }, connectedAccounts: { - list: async (query: unknown) => { - scoped.push((query as { authConfigIds?: unknown }).authConfigIds); - return { items: [{ id: "ca_1" }] }; - }, + list, delete: async (id: string) => { deleted.push(id); return WITHDRAWN; @@ -2421,14 +2694,18 @@ describe("withdrawing one person's grants", () => { expect( await broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); // The operator's config is not in the question, so no account on it can be in the answer and // none of them can reach the delete. - expect(scoped).toEqual([[OUR_GMAIL.id]]); + expect(asked.map((query) => query.authConfigIds)).toEqual([[OUR_GMAIL.id]]); expect(deleted).toEqual(["ca_1"]); }); @@ -2448,12 +2725,16 @@ describe("withdrawing one person's grants", () => { */ test("a person's accounts are not listed where Composio holds no config for the app", async () => { const { broker } = buildComposioClient( - fakeVendor({ authConfigs: { list: async () => ({ items: [] }) } }), + fakeVendor({ authConfigs: { list: configsOf("gmail", []) } }), ); expect( await broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(false); @@ -2483,13 +2764,17 @@ describe("withdrawing one person's grants", () => { test("an app whose configs no longer carry this deployment's name is not a quiet disconnection", async () => { const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [BY_HAND_GMAIL] }) }, + authConfigs: { list: configsOf("gmail", [BY_HAND_GMAIL]) }, }), ); const refusal = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -2530,7 +2815,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }] }), + list: heldAccounts([{ id: "ca_1" }]), delete: async (id: string) => { deleted.push(id); return answer; @@ -2544,7 +2829,7 @@ describe("withdrawing one person's grants", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -2576,10 +2861,10 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async (query: unknown) => - (query as { cursor?: string }).cursor === undefined - ? { items: [{ id: "ca_1" }], nextCursor: "page_2" } - : { items: [{ id: "ca_1" }], nextCursor: null }, + list: accountListing([ + { row: { id: "ca_1" }, page: 1 }, + { row: { id: "ca_1" }, page: 2 }, + ]).list, delete: async (id: string) => { if (deleted.includes(id)) { throw new Error( @@ -2595,7 +2880,11 @@ describe("withdrawing one person's grants", () => { expect( await broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); @@ -2615,9 +2904,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ - items: [{ id: "ca_1" }, { id: "ca_1" }, { id: "ca_2" }], - }), + list: heldAccounts([{ id: "ca_1" }, { id: "ca_1" }, { id: "ca_2" }]), delete: async (id: string) => { if (id === "ca_2") throw new Error("Composio refused this one."); return WITHDRAWN; @@ -2629,7 +2916,11 @@ describe("withdrawing one person's grants", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -2654,7 +2945,7 @@ describe("withdrawing one person's grants", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{}, { id: "ca_refused" }] }), + list: heldAccounts([{}, { id: "ca_refused" }]), delete: refuse("this account's withdrawal"), }, }), @@ -2663,7 +2954,11 @@ describe("withdrawing one person's grants", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -2697,12 +2992,10 @@ describe("withdrawing one person's grants", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_theirs", name: "Gmail", status: "ENABLED" }, - { id: "ac_nameless", status: "ENABLED" }, - ], - }), + list: configsOf("gmail", [ + { id: "ac_theirs", name: "Gmail", status: "ENABLED" }, + { id: "ac_nameless", status: "ENABLED" }, + ]), }, connectedAccounts: { delete: async (id: string) => { @@ -2716,7 +3009,11 @@ describe("withdrawing one person's grants", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -2750,7 +3047,11 @@ describe("refusals a route can tell from an outage", () => { const error = await broker .authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: "https://openbot.test/settings/connected-accounts/x", }) @@ -2781,7 +3082,11 @@ describe("refusals a route can tell from an outage", () => { const error = await broker .authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: "https://openbot.test/settings/connected-accounts/x", }) @@ -3186,7 +3491,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "linear", returnUrl: RETURN_URL, @@ -3202,7 +3507,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -3215,7 +3520,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -3228,7 +3533,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -3240,7 +3545,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { // deployment's to end before it asks for any account at all. authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }] }), + list: heldAccounts([{ id: "ca_1" }]), delete: raise, }, }), @@ -3249,7 +3554,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -3277,7 +3582,7 @@ describe("what a vendor failure becomes on its way out of the seam", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "linear", authScheme: "API_KEY", @@ -3452,7 +3757,11 @@ describe("each vendor condition reaches the reader as its own remedy", () => { () => 1_000_000, ); return broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }); @@ -3681,9 +3990,7 @@ describe("what the delete loop keeps of the failures it meets", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ - items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], - }), + list: heldAccounts([{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }]), delete: async (id: string) => { if (id !== "ca_1") throw new Error(`Composio refused ${id}.`); return WITHDRAWN; @@ -3695,7 +4002,11 @@ describe("what the delete loop keeps of the failures it meets", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -3715,7 +4026,7 @@ describe("what the delete loop keeps of the failures it meets", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: "ca_1" }, { id: "ca_2" }] }), + list: heldAccounts([{ id: "ca_1" }, { id: "ca_2" }]), delete: async (id: string) => { if (id === "ca_2") throw new Error("Composio refused that one."); return WITHDRAWN; @@ -3727,7 +4038,11 @@ describe("what the delete loop keeps of the failures it meets", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -3757,9 +4072,7 @@ describe("what the delete loop keeps of the failures it meets", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ - items: [{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }], - }), + list: heldAccounts([{ id: "ca_1" }, { id: "ca_2" }, { id: "ca_3" }]), delete: async (id: string) => { if (id === "ca_2") { throw new TypeError( @@ -3776,7 +4089,11 @@ describe("what the delete loop keeps of the failures it meets", () => { const failure = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -3795,7 +4112,7 @@ describe("what the delete loop keeps of the failures it meets", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [OURS, OURS_SPARE] }), + list: configsOf("linear", [OURS, OURS_SPARE]), delete: async (id: string) => { throw new Error(`Composio refused ${id}.`); }, @@ -3946,7 +4263,7 @@ describe("a vendor listing that is not the shape it is declared to be", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "linear", returnUrl: RETURN_URL, @@ -3997,7 +4314,7 @@ describe("a vendor listing that is not the shape it is declared to be", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -4104,18 +4421,16 @@ describe("a listing that arrived with a cursor still outstanding", () => { ]; test("every page of this person's accounts is read, and every account on them withdrawn", async () => { - const asked: unknown[] = []; const deleted: string[] = []; + const { list, asked } = accountListing([ + { row: { id: "ca_1" }, page: 1 }, + { row: { id: "ca_2" }, page: 2 }, + ]); const { broker } = buildComposioClient( fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async (query: unknown) => { - asked.push(query); - return (query as { cursor?: string }).cursor === undefined - ? { items: [{ id: "ca_1" }], nextCursor: "page_2" } - : { items: [{ id: "ca_2" }], nextCursor: null }; - }, + list, delete: async (id: string) => { deleted.push(id); return WITHDRAWN; @@ -4126,7 +4441,11 @@ describe("a listing that arrived with a cursor still outstanding", () => { expect( await broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); @@ -4139,7 +4458,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { // where it left off — which is the half a reader cannot infer from the rows that came back. expect(asked).toEqual([ { - userIds: ["user_1"], + userIds: [VENDOR_USER], toolkitSlugs: ["gmail"], statuses: REVOCABLE_STATUSES, accountType: "ALL", @@ -4147,7 +4466,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { limit: WHOLE_LISTING, }, { - userIds: ["user_1"], + userIds: [VENDOR_USER], toolkitSlugs: ["gmail"], statuses: REVOCABLE_STATUSES, accountType: "ALL", @@ -4162,10 +4481,8 @@ describe("a listing that arrived with a cursor still outstanding", () => { const { broker } = buildComposioClient( fakeVendor({ connectedAccounts: { - list: async (query: unknown) => - (query as { cursor?: string }).cursor === undefined - ? { items: [], nextCursor: "page_2" } - : { items: [{ id: "ca_2" }], nextCursor: null }, + // Page one holds only the accounts this question must leave out. + list: accountListing([{ row: { id: "ca_2" }, page: 2 }]).list, }, }), ); @@ -4175,7 +4492,11 @@ describe("a listing that arrived with a cursor still outstanding", () => { // tells the gate in `./access` that they may not act through one they can. expect( await broker.isConnected({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); @@ -4240,7 +4561,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -4273,7 +4594,11 @@ describe("a listing that arrived with a cursor still outstanding", () => { const failure = await failureOf( broker.isConnected({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -4337,7 +4662,11 @@ describe("a listing that arrived with a cursor still outstanding", () => { ); await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }); @@ -4345,7 +4674,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { // Reading one page here answers "this deployment has no config for linear" and sends an // administrator to remove and re-add an app whose config is sitting on page two. expect(linked).toEqual([ - ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], + [VENDOR_USER, "ac_ours", { callbackUrl: RETURN_URL }], ]); }); @@ -4370,7 +4699,11 @@ describe("a listing that arrived with a cursor still outstanding", () => { const refusal = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -4444,7 +4777,7 @@ describe("a listing that arrived with a cursor still outstanding", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -4510,7 +4843,11 @@ describe("a listing that arrived with a cursor still outstanding", () => { const refusal = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -4560,7 +4897,11 @@ describe("a listing that arrived with a cursor still outstanding", () => { const refusal = await failureOf( broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ); @@ -4748,7 +5089,11 @@ describe("a vendor answer that is one object rather than a listing", () => { */ const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -5057,7 +5402,7 @@ describe("what a malformed field of a row actually costs", () => { const { actions } = buildComposioClient( fakeVendor({ tools: { - list: async () => ({ items: [{ slug: "GMAIL_FETCH_EMAILS" }] }), + list: actionsOf("gmail", [{ slug: "GMAIL_FETCH_EMAILS" }]), }, }), ); @@ -5096,16 +5441,14 @@ describe("what a malformed field of a row actually costs", () => { const { actions } = buildComposioClient( fakeVendor({ tools: { - list: async () => ({ - items: [ - { - slug: "GMAIL_FETCH_EMAILS", - description: null, - input_parameters: null, - version: null, - }, - ], - }), + list: actionsOf("gmail", [ + { + slug: "GMAIL_FETCH_EMAILS", + description: null, + input_parameters: null, + version: null, + }, + ]), }, }), ); @@ -5277,7 +5620,11 @@ describe("what a malformed field of a row actually costs", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -5328,7 +5675,11 @@ describe("what a malformed field of a row actually costs", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -5372,7 +5723,11 @@ describe("what a malformed field of a row actually costs", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -5405,7 +5760,11 @@ describe("what a malformed field of a row actually costs", () => { const refusal = await failureOf( broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: "pplx-secret" }, @@ -5446,7 +5805,11 @@ describe("what a malformed field of a row actually costs", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -5484,7 +5847,11 @@ describe("what a malformed field of a row actually costs", () => { ); await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }); @@ -5496,7 +5863,9 @@ describe("what a malformed field of a row actually costs", () => { * Connect and an administrator pressing Remove, who need not be answered by the same process, * container or build of ICU. This pair is the one that tells the two orders apart. */ - expect(linked).toEqual([["user_1", "ac_B", { callbackUrl: RETURN_URL }]]); + expect(linked).toEqual([ + [VENDOR_USER, "ac_B", { callbackUrl: RETURN_URL }], + ]); }); test("a created config the answer does not name is reported as possibly standing", async () => { @@ -5598,7 +5967,7 @@ describe("what a malformed field of a row actually costs", () => { account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "linear", returnUrl: RETURN_URL, @@ -5666,11 +6035,9 @@ describe("a field Composio padded with whitespace", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: " ac_ours ", name: "Linear (OpenBot)", status: "ENABLED" }, - ], - }), + list: configsOf("linear", [ + { id: " ac_ours ", name: "Linear (OpenBot)", status: "ENABLED" }, + ]), delete: async (...call: unknown[]) => { deleted.push(call); }, @@ -5691,7 +6058,7 @@ describe("a field Composio padded with whitespace", () => { fakeVendor({ authConfigs: { list: ourGmailConfig }, connectedAccounts: { - list: async () => ({ items: [{ id: " ca_1 " }] }), + list: heldAccounts([{ id: " ca_1 " }]), delete: async (...call: unknown[]) => { deleted.push(call); return WITHDRAWN; @@ -5702,7 +6069,11 @@ describe("a field Composio padded with whitespace", () => { expect( await broker.revoke({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", }), ).toBe(true); @@ -5777,7 +6148,7 @@ describe("a field Composio padded with whitespace", () => { test("a padded consent page is the address this person is sent to, without the padding", async () => { const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [OURS] }) }, + authConfigs: { list: configsOf("linear", [OURS]) }, connectedAccounts: { link: async () => ({ redirectUrl: " https://backend.composio.dev/s/a-link ", @@ -5788,7 +6159,11 @@ describe("a field Composio padded with whitespace", () => { ); const begun = await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }); @@ -5812,7 +6187,7 @@ describe("a field Composio padded with whitespace", () => { const linked: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [OURS] }) }, + authConfigs: { list: configsOf("linear", [OURS]) }, connectedAccounts: { link: async (...call: unknown[]) => { linked.push(call); @@ -5825,7 +6200,11 @@ describe("a field Composio padded with whitespace", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -5853,11 +6232,9 @@ describe("a field Composio padded with whitespace", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_ours", name: "Linear (OpenBot)", status: " ENABLED " }, - ], - }), + list: configsOf("linear", [ + { id: "ac_ours", name: "Linear (OpenBot)", status: " ENABLED " }, + ]), }, connectedAccounts: { link: async (...call: unknown[]) => { @@ -5870,7 +6247,11 @@ describe("a field Composio padded with whitespace", () => { ); const begun = await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }); @@ -5881,7 +6262,7 @@ describe("a field Composio padded with whitespace", () => { authorizationId: null, }); expect(linked).toEqual([ - ["user_1", "ac_ours", { callbackUrl: RETURN_URL }], + [VENDOR_USER, "ac_ours", { callbackUrl: RETURN_URL }], ]); }); @@ -5891,11 +6272,9 @@ describe("a field Composio padded with whitespace", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_ours", name: "Linear (OpenBot)", status: " ENABLED " }, - ], - }), + list: configsOf("linear", [ + { id: "ac_ours", name: "Linear (OpenBot)", status: " ENABLED " }, + ]), }, connectedAccounts: { create: async (body: unknown) => { @@ -5908,7 +6287,11 @@ describe("a field Composio padded with whitespace", () => { expect( await broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: "pplx-secret" }, @@ -5928,11 +6311,9 @@ describe("a field Composio padded with whitespace", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_ours", name: "Linear (OpenBot)", status: " DISABLED " }, - ], - }), + list: configsOf("linear", [ + { id: "ac_ours", name: "Linear (OpenBot)", status: " DISABLED " }, + ]), }, }), () => 1_000_000, @@ -5940,7 +6321,11 @@ describe("a field Composio padded with whitespace", () => { const refusal = await failureOf( broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", returnUrl: RETURN_URL, }), @@ -6131,7 +6516,7 @@ describe("the key becoming a vendor, and which delete that vendor carries", () = account: { holder: "person", userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }, toolkit: "gmail", }), @@ -6207,7 +6592,11 @@ describe("the key becoming a vendor, and which delete that vendor carries", () = const { broker } = createComposioClient("never-dialled"); begun = await broker.authorize({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "gmail", returnUrl: RETURN_URL, }); @@ -6218,7 +6607,7 @@ describe("the key becoming a vendor, and which delete that vendor carries", () = // The person, then the config, then the callback — and the whole list rather than a field of // it, because the defect this is written for is an order and not a value. expect(minted).toEqual([ - ["user_1", OUR_GMAIL.id, { callbackUrl: RETURN_URL }], + [VENDOR_USER, OUR_GMAIL.id, { callbackUrl: RETURN_URL }], ]); expect(begun).toEqual({ redirectUrl: "https://backend.composio.dev/s/a-link", @@ -7856,7 +8245,7 @@ describe("connecting one person with the secret they typed", () => { const asked: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [OURS] }) }, + authConfigs: { list: configsOf("linear", [OURS]) }, connectedAccounts: { create: async (body: unknown) => { asked.push(body); @@ -7868,7 +8257,11 @@ describe("connecting one person with the secret they typed", () => { expect( await broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET, subdomain: "acme" }, @@ -7886,7 +8279,7 @@ describe("connecting one person with the secret they typed", () => { { auth_config: { id: OURS.id }, connection: { - user_id: "user_1", + user_id: VENDOR_USER, state: { authScheme: "API_KEY", val: { @@ -7917,7 +8310,7 @@ describe("connecting one person with the secret they typed", () => { const asked: unknown[] = []; const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [OURS] }) }, + authConfigs: { list: configsOf("linear", [OURS]) }, connectedAccounts: { create: async (body: unknown) => { asked.push(body); @@ -7928,7 +8321,11 @@ describe("connecting one person with the secret they typed", () => { ); await broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET, status: "INITIALIZING" }, @@ -7938,7 +8335,7 @@ describe("connecting one person with the secret they typed", () => { { auth_config: { id: OURS.id }, connection: { - user_id: "user_1", + user_id: VENDOR_USER, state: { authScheme: "API_KEY", // The word this deployment sets, whatever arrived beside it under the same name. @@ -7983,7 +8380,7 @@ describe("connecting one person with the secret they typed", () => { const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [OURS] }) }, + authConfigs: { list: configsOf("linear", [OURS]) }, connectedAccounts: { create: async () => { throw raised; @@ -7994,7 +8391,11 @@ describe("connecting one person with the secret they typed", () => { const refusal = await failureOf( broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET }, @@ -8018,14 +8419,18 @@ describe("connecting one person with the secret they typed", () => { test("a reply with no account id is refused rather than answered as a connection", async () => { const { broker } = buildComposioClient( fakeVendor({ - authConfigs: { list: async () => ({ items: [OURS] }) }, + authConfigs: { list: configsOf("linear", [OURS]) }, connectedAccounts: { create: async () => ({ status: "ACTIVE" }) }, }), ); const refusal = await failureOf( broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET }, @@ -8048,13 +8453,17 @@ describe("connecting one person with the secret they typed", () => { fakeVendor({ // The create is left at `fakeVendor`'s refusal, which is what says the value typed in went // nowhere: a call that was made would name itself here rather than answering. - authConfigs: { list: async () => ({ items: [BY_HAND] }) }, + authConfigs: { list: configsOf("linear", [BY_HAND]) }, }), ); const refusal = await failureOf( broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET }, @@ -8087,11 +8496,9 @@ describe("connecting one person with the secret they typed", () => { * back with an account that cannot work. */ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, - ], - }), + list: configsOf("linear", [ + { id: "ac_ours", name: "Linear (OpenBot)", status: "DISABLED" }, + ]), }, connectedAccounts: { create: async (body: unknown) => { @@ -8104,7 +8511,11 @@ describe("connecting one person with the secret they typed", () => { const refusal = await failureOf( broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET }, @@ -8135,12 +8546,10 @@ describe("connecting one person with the secret they typed", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ - items: [ - { id: "ac_a", name: "Linear (OpenBot)", status: "DISABLED" }, - { id: "ac_b", name: "Linear (OpenBot)", status: "PENDING" }, - ], - }), + list: configsOf("linear", [ + { id: "ac_a", name: "Linear (OpenBot)", status: "DISABLED" }, + { id: "ac_b", name: "Linear (OpenBot)", status: "PENDING" }, + ]), }, connectedAccounts: { create: async (body: unknown) => { @@ -8153,7 +8562,11 @@ describe("connecting one person with the secret they typed", () => { const refusal = await failureOf( broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET }, @@ -8173,7 +8586,7 @@ describe("connecting one person with the secret they typed", () => { const { broker } = buildComposioClient( fakeVendor({ authConfigs: { - list: async () => ({ items: [{ id: "ac_nameless" }] }), + list: configsOf("linear", [{ id: "ac_nameless" }]), }, connectedAccounts: { create: async (body: unknown) => { @@ -8186,7 +8599,11 @@ describe("connecting one person with the secret they typed", () => { const refusal = await failureOf( broker.connectWithFields({ - account: { holder: "person", userId: "user_1", vendorUserId: "user_1" }, + account: { + holder: "person", + userId: "user_1", + vendorUserId: VENDOR_USER, + }, toolkit: "linear", authScheme: "API_KEY", values: { generic_api_key: TYPED_SECRET }, @@ -8413,20 +8830,42 @@ describe("accounts named by holder", () => { const person = { holder: "person" as const, userId: "user_1", - vendorUserId: "user_1", + vendorUserId: VENDOR_USER, }; + /** + * Accounts named the way a wrong question would find them, each carrying a name of its own so + * that the name an answer comes back with says which question was asked. + */ + const NAMED_ELSEWHERE: HeldAccount[] = [ + { + row: { id: "ca_person", data: { displayName: "a person's github" } }, + toolkit: "github", + }, + { + row: { id: "ca_local", data: { displayName: "under the local id" } }, + userId: "user_1", + toolkit: "github", + }, + { + row: { id: "ca_gmail", data: { displayName: "the deployment's gmail" } }, + userId: deployment.vendorUserId, + }, + ]; + test("the deployment's account is asked for under its vendor id, never a person's", async () => { - const asked: unknown[] = []; - const { broker } = buildComposioClient( - fakeVendor({ - connectedAccounts: { - list: async (query: unknown) => { - asked.push(query); - return { items: [{ id: "ca_1" }] }; - }, + const { list, asked } = accountListing( + [ + { + row: { id: "ca_1" }, + userId: deployment.vendorUserId, + toolkit: "github", }, - }), + ], + NAMED_ELSEWHERE, + ); + const { broker } = buildComposioClient( + fakeVendor({ connectedAccounts: { list } }), ); expect( await broker.isConnected({ account: deployment, toolkit: "github" }), @@ -8436,43 +8875,77 @@ describe("accounts named by holder", () => { ]); }); + /* + * NOT CONNECTED, WHILE THE VENDOR HOLDS A GITHUB ACCOUNT UNDER THIS PERSON'S LOCAL ID. The `false` + * is what tells a question asked under `userId` from one asked under `vendorUserId`: with the two + * spelled alike, as they were here, the assertion on `userIds` could not tell them apart either. + */ test("a person's account is asked for under their vendor id", async () => { - const asked: unknown[] = []; + const { list, asked } = accountListing( + [], + NAMED_ELSEWHERE.filter((account) => account.userId === "user_1"), + ); const { broker } = buildComposioClient( - fakeVendor({ - connectedAccounts: { - list: async (query: unknown) => { - asked.push(query); - return { items: [] }; - }, - }, - }), + fakeVendor({ connectedAccounts: { list } }), ); expect( await broker.isConnected({ account: person, toolkit: "github" }), ).toBe(false); - expect(asked).toEqual([expect.objectContaining({ userIds: ["user_1"] })]); + expect(asked).toEqual([ + expect.objectContaining({ userIds: [VENDOR_USER] }), + ]); + }); + + /* + * THE NAME IS READ OFF THIS PERSON'S ACCOUNT FOR THIS APP, which nothing here asked of a person. + * Every name test below is the deployment's; a person's name read under their local id, or off + * another app's account, is a name a settings page shows them for an account that is not theirs. + */ + test("a person's account name is read off their own account for that app", async () => { + const { list, asked } = accountListing( + [ + { + row: { id: "ca_mine", data: { displayName: "ada@example.test" } }, + toolkit: "github", + }, + ], + NAMED_ELSEWHERE.slice(1), + ); + const { broker } = buildComposioClient( + fakeVendor({ connectedAccounts: { list } }), + ); + expect( + await broker.accountName({ account: person, toolkit: "github" }), + ).toBe("ada@example.test"); + expect(asked.map((query) => [query.userIds, query.toolkitSlugs])).toEqual([ + [[VENDOR_USER], ["github"]], + ]); }); test("only the display name leaves a listing that carries the account's tokens", async () => { const { broker } = buildComposioClient( fakeVendor({ connectedAccounts: { - list: async () => ({ - items: [ + list: accountListing( + [ { - id: "ca_1", - data: { - displayName: "acme-bot", - access_token: "gho_SECRET", - refresh_token: "r_SECRET", - client_secret: "cs_SECRET", - id_token: "eyJ.SECRET", + row: { + id: "ca_1", + data: { + displayName: "acme-bot", + access_token: "gho_SECRET", + refresh_token: "r_SECRET", + client_secret: "cs_SECRET", + id_token: "eyJ.SECRET", + }, + state: { val: { access_token: "gho_SECRET" } }, }, - state: { val: { access_token: "gho_SECRET" } }, + userId: deployment.vendorUserId, + toolkit: "github", }, ], - }), + NAMED_ELSEWHERE, + ).list, }, }), ); @@ -8488,9 +8961,21 @@ describe("accounts named by holder", () => { const { broker } = buildComposioClient( fakeVendor({ connectedAccounts: { - list: async () => ({ - items: [{ id: "ca_1", data: { id_token: "eyJhbGciOi.e30.x" } }], - }), + // Beside a person's NAMED gmail account, so a question asked under anybody but the + // deployment's id answers with somebody's name rather than with none. + list: accountListing( + [ + { + row: { id: "ca_1", data: { id_token: "eyJhbGciOi.e30.x" } }, + userId: deployment.vendorUserId, + }, + ], + [ + { + row: { id: "ca_p", data: { displayName: "a person's gmail" } }, + }, + ], + ).list, }, }), ); From f4f6974bbe181419531d7c13bd758ea7bb77ede5 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 13:07:12 +0200 Subject: [PATCH 207/232] fix(tests): carry C2a's authorization field into G14's and B8a's grant literals C2a made `authorization: "held" | "not-required"` a REQUIRED field on an authorized grant, so that the answer can tell a no-auth action from a held one with no scopes -- the distinction its approval count depends on. It updated both adapters: arcade-adapter.ts emits "not-required" for an action publishing no authorization and "held" otherwise, and composio-adapter.ts emits "held" unconditionally, because a Composio entry is authorized only because this person holds an account. G14 and B8a were written in parallel against the old shape and never saw the new field: three expectation literals in composio-grants-for.test.ts and one GrantState fixture in connected-account-landing-param.test.tsx. Each was green on its own branch and failed only once merged -- CC-D, the class where individually-correct changes compose into a defect, caught here by typecheck before the merge was finalized rather than by a later round. PRODUCTION IS UNCHANGED BY THIS COMMIT. Both adapters already set the field; only the stale expectations are carried forward, and all four take "held" because every one is a scoped action authorized through an account the person holds. typecheck 0, typecheck:tests clean against baseline 264, and composio-grants-for + arcade-adapter-status + arcade-grants-route 32 pass / 0 fail. Co-Authored-By: Claude Opus 5 (1M context) --- app/tests/connected-account-landing-param.test.tsx | 1 + server/tests/composio-grants-for.test.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/tests/connected-account-landing-param.test.tsx b/app/tests/connected-account-landing-param.test.tsx index 453ed6c72..2d202ab1d 100644 --- a/app/tests/connected-account-landing-param.test.tsx +++ b/app/tests/connected-account-landing-param.test.tsx @@ -44,6 +44,7 @@ const GMAIL: GrantState[] = [ action: "Gmail.ListEmails", state: "authorized", scopes: ["gmail.readonly"], + authorization: "held", }, { action: "Gmail.SendEmail", state: "needs-consent", scopes: ["gmail.send"] }, ]; diff --git a/server/tests/composio-grants-for.test.ts b/server/tests/composio-grants-for.test.ts index 024f37ff1..e8d893734 100644 --- a/server/tests/composio-grants-for.test.ts +++ b/server/tests/composio-grants-for.test.ts @@ -478,6 +478,7 @@ describe("Composio's grantsFor", () => { action: "GMAIL_FETCH_EMAILS", state: "authorized", scopes: ["gmail.modify"], + authorization: "held", }, ]); // And the gate agrees, which is the consistency the state is measured for. @@ -517,6 +518,7 @@ describe("Composio's grantsFor", () => { action: "GMAIL_FETCH_EMAILS", state: "authorized", scopes: ["gmail.modify", "gmail.readonly", "gmail.send"], + authorization: "held", }, ]); }); @@ -548,7 +550,12 @@ describe("Composio's grantsFor", () => { expect( await broker.grantsFor({ toolkit: "gmail", account: ACCOUNT }), ).toEqual([ - { action: "GMAIL_FETCH_EMAILS", state: "authorized", scopes: [] }, + { + action: "GMAIL_FETCH_EMAILS", + state: "authorized", + scopes: [], + authorization: "held", + }, ]); }); From c995b6872cdff0c3e78d2462356f1dcf485713fd Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:06:48 +0200 Subject: [PATCH 208/232] test(fixtures): pin local id and vendor id distinct in account fixtures (lever L1b) Adds server/tests/fixture-identity-distinct.test.ts: a TypeScript-AST scan of every object literal under server/tests and app/tests naming both userId and vendorUserId (or user_id and vendor_user_id), comparing the two values after resolving same-file const bindings. It is a ratchet keyed by path + quoted expression + count, never path:line. The 142 collapses standing at f4f6974b are recorded across 22 files. Every broker-adapter suite, composio-adapter.test.ts included, has an allowance of zero. A second rule fails when a literal is a local id in one fixture and a vendor id in another (none today). Strictly additive: no production file or existing test changed. Co-Authored-By: Claude Opus 5.5 --- .../tests/fixture-identity-distinct.test.ts | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 server/tests/fixture-identity-distinct.test.ts diff --git a/server/tests/fixture-identity-distinct.test.ts b/server/tests/fixture-identity-distinct.test.ts new file mode 100644 index 000000000..9fc377931 --- /dev/null +++ b/server/tests/fixture-identity-distinct.test.ts @@ -0,0 +1,421 @@ +// server/tests/fixture-identity-distinct.test.ts +/** + * Guard: an account fixture's LOCAL id and its VENDOR id are not the same value. + * + * WHY THIS EXISTS. An account ref carries two ids for two different people-in-two-systems: + * `userId` is who the person is HERE, `vendorUserId` is who the broker knows them as. A broker that + * asks the vendor under `userId` is asking about somebody else. Round 2's A8a found every one of the + * 79 person fixtures in `composio-adapter.test.ts` spelling both as `"user_1"` — so no test could + * tell the two apart, and five planted wrong-identity defects (`revoke`, `isConnected`, + * `accountName`, `authorize`, `connectWithFields` each asking under `account.userId`) passed 245 of + * 245 tests. A8a separated the ids and the same plants now redden 1 to 18 tests each. Nothing + * stopped the collapse coming back; this does. + * + * WHAT IT SEES. Every object literal, in every `.ts`/`.tsx` file under the scanned roots, that + * names BOTH fields of a pair in {@link PAIRS}. The two values are compared after resolving + * same-file `const` bindings, so `vendorUserId: VENDOR_USER` with `const VENDOR_USER = "user_1"` + * beside `userId: "user_1"` IS a collapse — that is A8a's exact shape, and a purely textual compare + * would miss it. Shorthand (`{ userId, vendorUserId: userId }`) is read as the name it binds. + * + * WHAT IT DOES NOT SEE — the stated boundary, so nobody reads this as wider than it is: + * - a value imported from another file is compared by NAME, not resolved; + * - a `let`, a parameter, or a name bound more than once in the file is compared by name, not + * value (two different parameters that receive the same string at runtime are invisible); + * - an object built by spread (`{ ...base, vendorUserId: x }`) or by assignment after the fact — + * only literals naming both fields are read; + * - raw SQL (`INSERT … (user_id, vendor_user_id) VALUES …`) — strings are not parsed; + * - production code: `server/src` is not scanned. See the note on `accountRefFor` below. + * + * NOT EVERY COLLAPSE IS A DEFECT, WHICH IS WHY THIS IS A RATCHET AND NOT A BAN. Production's + * `accountRefFor` (`server/src/plugins/store.ts`) answers a Personal app with + * `{ holder: "person", userId: actorId, vendorUserId: actorId }` — for a person, the vendor identity + * IS the local id today. A store-level test that seeds a `brokered_connections` row or expects that + * ref is right to collapse the two: it mirrors what production writes. A test that hands an account + * STRAIGHT TO A BROKER is not — the broker's whole job there is to pick the right field, and a + * collapsed fixture makes that choice untestable. The scan cannot tell those apart, so every + * collapse standing today is recorded in {@link KNOWN_COLLAPSES}, keyed by path + quoted expression + * with a count (never `path:line` — the line-keyed ALLOW list in `assertion-shape.test.ts` collided + * three waves running; `sole-definition.test.ts`'s path-keyed list never has). Equality both ways: + * + * - MORE fails: a new collapse anywhere — including any file with no entry at all, which is + * every broker-adapter suite today. Separate the ids; if the new site truly mirrors + * `accountRefFor`'s person ref, raise that entry's count and say so in the commit. + * - FEWER fails: a collapse was separated, so the list is stale. Lower or delete the entry. + * + * A SECOND RULE, ACROSS FIXTURES. Within one file, no literal id may be a local id in one fixture + * and a vendor id in another. Two crossed fixtures are as blind as one collapsed one: a broker that + * asks under the wrong field finds the other fixture's account and the test passes. + */ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { sourceFiles } from "./support/source-scan"; + +const REPO = join(import.meta.dir, "../.."); + +/** Where fixtures live. Production is deliberately absent — see the module comment. */ +const ROOTS = ["server/tests", "app/tests"]; + +/** One row per pair of fields that name the same person in two systems. Add pairs as rows. */ +const PAIRS: readonly { local: string; vendor: string }[] = [ + { local: "userId", vendor: "vendorUserId" }, + { local: "user_id", vendor: "vendor_user_id" }, +]; + +/** + * Collapses standing at `f4f6974b`, by file, as `" | ": count`. + * + * RECORDED, NOT BLESSED. These are of three kinds, and only the first two are harmless: + * + * - ROWS seeded the way production writes them (`brokered_connections`, `pending_authorizations` + * for a person, vendor id = actor id), and refs a fake `accountRefFor` answers in that shape. + * - EXPECTATIONS of what a route handed a broker spy, after the real `accountRefFor` collapsed + * the two — right for as long as production does. + * - REFS FED STRAIGHT TO A STORE METHOD OR A BROKER — the A8a shape one layer up. The store reads + * `account.vendorUserId` to find rows, and with both ids equal nothing in these files can tell + * if it read `userId` instead. Known: `composio-connections.test.ts` (`askerId` into + * `connectBrokeredWithFields`, `recheckBrokeredConnection`, `confirmBrokeredConnection`, + * `disconnectBrokered`, `probeBrokeredConnection`), `composio-connection-kinds.test.ts` + * (`PERSON_ACCOUNT`), `arcade-disconnect-cascade.test.ts` (`disconnectBrokered`), + * `arcade-siblings.test.ts` (`siblingApps`), `store-vendor-vocabulary.test.ts` + * (`recheckBrokeredConnection`), and `composio-broker-seam.test.ts` / + * `test-network-guard.test.ts` (`broker.authorize`, whose subject there is transport, not + * identity). Separating these is fix-cycle work; when one is separated this list goes red + * until its entry is lowered, which is the point. + * + * Every broker-adapter suite (`composio-adapter`, `arcade-adapter*`, `arcade-*-engine`, + * `composio-grants-for`, …) has NO entry, so its allowance is zero. + */ +const KNOWN_COLLAPSES: Record> = { + "server/tests/account-mode.integration.test.ts": { + "`p1-${suite}` | `p1-${suite}`": 1, + "`p2-${suite}` | `p2-${suite}`": 1, + }, + "server/tests/arcade-authorize-route.test.ts": { + "actor | actor": 1, + }, + "server/tests/arcade-disconnect-cascade.test.ts": { + "userId | userId": 2, + }, + "server/tests/arcade-grants-route.test.ts": { + "n.viewer | n.viewer": 2, + }, + "server/tests/arcade-journey.integration.test.ts": { + "ADMIN | ADMIN": 2, + }, + "server/tests/arcade-siblings.test.ts": { + "mine | mine": 1, + "userId | userId": 5, + }, + "server/tests/arcade-verify.test.ts": { + "ACTOR | ACTOR": 3, + "vendorUserId | vendorUserId": 1, + }, + "server/tests/composio-broker-seam.test.ts": { + '"u" | "u"': 1, + }, + "server/tests/composio-connection-kinds.test.ts": { + "person | person": 14, + }, + "server/tests/composio-connections.test.ts": { + '"" | ""': 3, + "askerId | askerId": 59, + "leaverId | leaverId": 4, + }, + "server/tests/memory-defaults.integration.test.ts": { + "owner | owner": 1, + }, + "server/tests/plugin-routes.test.ts": { + "ADMIN.id | ADMIN.id": 1, + "DEV_ACTOR.id | DEV_ACTOR.id": 1, + "actorId | actorId": 1, + }, + "server/tests/plugin-store.integration.test.ts": { + '"" | ""': 2, + '"user-a" | "user-a"': 1, + '"user-b" | "user-b"': 1, + '"user_asker" | "user_asker"': 2, + '"user_leaver" | "user_leaver"': 4, + "userId | userId": 14, + }, + "server/tests/remove-server-foreign-provenance.test.ts": { + "userId | userId": 1, + }, + "server/tests/routes-arcade-urls.test.ts": { + '"admin-1" | "admin-1"': 1, + "actorId | actorId": 1, + }, + "server/tests/routes-vendor-vocabulary.test.ts": { + "actorId | actorId": 1, + }, + "server/tests/shared-brokered-schema.integration.test.ts": { + "`a-${suite}` | `a-${suite}`": 2, + "`b-${suite}` | `b-${suite}`": 1, + }, + "server/tests/shared-brokered-store.integration.test.ts": { + "asker | asker": 2, + }, + "server/tests/store-scheme-sites.test.ts": { + "userId | userId": 1, + }, + "server/tests/store-vendor-vocabulary.test.ts": { + '"person-1" | "person-1"': 2, + }, + "server/tests/test-network-guard.test.ts": { + '"u" | "u"': 1, + }, + "server/tests/vendor-deadline-sync-throw.test.ts": { + "actorId | actorId": 1, + }, +}; + +/** A literal that was a local id in one fixture and a vendor id in another, by file. */ +const KNOWN_CROSSINGS: Record = {}; + +type Site = { + path: string; + local: string; + vendor: string; + localValue: string; + vendorValue: string; +}; + +/** Every `const NAME = ` in the file whose NAME is bound exactly once there. */ +function constBindings(source: ts.SourceFile): Map { + const seen = new Map(); + const init = new Map(); + const bind = (name: ts.BindingName) => { + if (ts.isIdentifier(name)) + seen.set(name.text, (seen.get(name.text) ?? 0) + 1); + else + for (const el of name.elements) + if (!ts.isOmittedExpression(el)) bind(el.name); + }; + const visit = (node: ts.Node) => { + if (ts.isVariableDeclaration(node)) { + bind(node.name); + const isConst = + ts.isVariableDeclarationList(node.parent) && + (node.parent.flags & ts.NodeFlags.Const) !== 0; + if (isConst && ts.isIdentifier(node.name) && node.initializer) { + init.set(node.name.text, node.initializer); + } + } else if (ts.isParameter(node)) bind(node.name); + else if ( + (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && + node.name + ) { + bind(node.name); + } + ts.forEachChild(node, visit); + }; + visit(source); + for (const [name, count] of seen) if (count > 1) init.delete(name); + return init; +} + +/** The value an expression stands for, as far as one file can say: a quoted string, or its text. */ +function resolve( + expr: ts.Expression, + source: ts.SourceFile, + consts: Map, + depth = 0, +): string { + if (depth > 8) return expr.getText(source); + if ( + ts.isParenthesizedExpression(expr) || + ts.isAsExpression(expr) || + ts.isSatisfiesExpression(expr) || + ts.isNonNullExpression(expr) + ) { + return resolve(expr.expression, source, consts, depth + 1); + } + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + return JSON.stringify(expr.text); + } + if (ts.isIdentifier(expr)) { + const bound = consts.get(expr.text); + return bound ? resolve(bound, source, consts, depth + 1) : expr.text; + } + if (ts.isTemplateExpression(expr)) { + const parts = [expr.head.text]; + for (const span of expr.templateSpans) { + parts.push(`\${${resolve(span.expression, source, consts, depth + 1)}}`); + parts.push(span.literal.text); + } + return `\`${parts.join("")}\``; + } + return expr.getText(source).replace(/\s+/g, " "); +} + +/** Every object literal in `text` that names both fields of a pair, with both values resolved. */ +function pairSites(path: string, text: string): Site[] { + const kind = path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const source = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + kind, + ); + const consts = constBindings(source); + const out: Site[] = []; + const field = (node: ts.ObjectLiteralExpression, key: string) => + node.properties.find( + (p) => + p.name !== undefined && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && + p.name.text === key, + ); + const readField = ( + p: ts.ObjectLiteralElementLike, + ): [string, string] | undefined => { + if (ts.isPropertyAssignment(p)) { + return [ + p.initializer.getText(source).replace(/\s+/g, " "), + resolve(p.initializer, source, consts), + ]; + } + if (ts.isShorthandPropertyAssignment(p)) { + return [p.name.text, resolve(p.name, source, consts)]; + } + return undefined; + }; + const visit = (node: ts.Node) => { + if (ts.isObjectLiteralExpression(node)) { + for (const { local, vendor } of PAIRS) { + const l = field(node, local); + const v = field(node, vendor); + const lv = l && readField(l); + const vv = v && readField(v); + if (lv && vv) { + out.push({ + path, + local: lv[0], + vendor: vv[0], + localValue: lv[1], + vendorValue: vv[1], + }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return out; +} + +function scan(): Site[] { + const out: Site[] = []; + for (const root of ROOTS) { + const full = join(REPO, root); + if (!existsSync(full)) continue; + for (const file of sourceFiles(full, [".ts", ".tsx"])) { + out.push( + ...pairSites(file.slice(REPO.length + 1), readFileSync(file, "utf8")), + ); + } + } + return out; +} + +const isCollapse = (s: Site) => s.localValue === s.vendorValue; +const isLiteral = (v: string) => v.startsWith('"'); + +/** Collapses by file, as `" | ": count`. */ +function collapsesOf(sites: Site[]): Record> { + const out: Record> = {}; + for (const s of sites.filter(isCollapse)) { + const byExpr = out[s.path] ?? {}; + out[s.path] = byExpr; + const key = `${s.local} | ${s.vendor}`; + byExpr[key] = (byExpr[key] ?? 0) + 1; + } + return out; +} + +/** Literal ids used as a local id in one distinct fixture and a vendor id in another, by file. */ +function crossingsOf(sites: Site[]): Record { + const byFile = new Map< + string, + { locals: Set; vendors: Set } + >(); + for (const s of sites) { + if (isCollapse(s)) continue; + const f = byFile.get(s.path) ?? { locals: new Set(), vendors: new Set() }; + byFile.set(s.path, f); + if (isLiteral(s.localValue)) f.locals.add(s.localValue); + if (isLiteral(s.vendorValue)) f.vendors.add(s.vendorValue); + } + const out: Record = {}; + for (const [path, { locals, vendors }] of byFile) { + const both = [...locals].filter((v) => vendors.has(v)).sort(); + if (both.length > 0) out[path] = both; + } + return out; +} + +const sites = scan(); + +test("the scan reaches the fixtures it claims to cover", () => { + // An enumerator that silently stopped seeing files would turn every rule below green. + // A8a's suite alone holds 79 person fixtures naming both ids. + const adapter = sites.filter( + (s) => s.path === "server/tests/composio-adapter.test.ts", + ); + expect(adapter.length).toBeGreaterThanOrEqual(79); + expect(new Set(sites.map((s) => s.path)).size).toBeGreaterThanOrEqual(40); +}); + +test("no account fixture names its local id and its vendor id as the same value, beyond the record", () => { + expect(collapsesOf(sites)).toEqual(KNOWN_COLLAPSES); +}); + +test("no literal id is a local id in one fixture and a vendor id in another, beyond the record", () => { + expect(crossingsOf(sites)).toEqual(KNOWN_CROSSINGS); +}); + +test("positive control: A8a's collapse, spelled through a const, is seen", () => { + const collapsed = pairSites( + "x.test.ts", + `const VENDOR_USER = "user_1"; + broker.revoke({ account: { holder: "person", userId: "user_1", vendorUserId: VENDOR_USER } });`, + ); + expect(collapsesOf(collapsed)).toEqual({ + "x.test.ts": { '"user_1" | VENDOR_USER': 1 }, + }); + const separated = pairSites( + "x.test.ts", + `const VENDOR_USER = "composio_user_1"; + broker.revoke({ account: { holder: "person", userId: "user_1", vendorUserId: VENDOR_USER } });`, + ); + expect(collapsesOf(separated)).toEqual({}); +}); + +test("positive control: shorthand, snake_case, and crossed fixtures are seen", () => { + const sitesOf = pairSites( + "y.test.ts", + `function hold(vendorUserId: string) { return { userId: vendorUserId, vendorUserId }; } + const row = { user_id: \`u-\${suite}\`, vendor_user_id: \`u-\${suite}\` }; + const a = { userId: "alice", vendorUserId: "v_alice" }; + const b = { userId: "v_alice", vendorUserId: "v_bob" };`, + ); + expect(collapsesOf(sitesOf)).toEqual({ + "y.test.ts": { + "vendorUserId | vendorUserId": 1, + "`u-${suite}` | `u-${suite}`": 1, + }, + }); + expect(crossingsOf(sitesOf)).toEqual({ "y.test.ts": ['"v_alice"'] }); +}); + +test("negative control: a name bound twice is not resolved through either binding", () => { + // Two `const id` in different scopes: resolving either would invent a collapse or hide one. + const sitesOf = pairSites( + "z.test.ts", + `{ const id = "a"; } { const id = "b"; } + const ref = { userId: "a", vendorUserId: id };`, + ); + expect(collapsesOf(sitesOf)).toEqual({}); +}); From 8c4febbbabfa04863a0b123d37220a5574ac45aa Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:07:41 +0200 Subject: [PATCH 209/232] test: add a mutation register that plants known defects and requires named suites to go red `bun run test:mutations` (scripts/mutation-register.ts) applies each entry's mutation IN MEMORY through a bun loader plugin (scripts/mutation-register-preload.ts), so the tree on disk is never written and a crash cannot leave it mutated. Each entry is keyed by quoted expression (plus an optional unique anchor, with a 600-character window so a deleted line cannot re-aim at a sibling), never by path:line. Per entry: the named suites must pass unmutated (control), the mutant must be reached (preload mark), and at least one (fail) test must appear. `survivesUntil` records a known hole as debt; a debt entry whose mutant is killed fails as stale. 10 entries across the five collapse kinds: identity (2), casing (3), absent-vs-unreadable (2), query scope (2), call-made (1). Two survive and are recorded as debt owned by D6: dropping the fold on the mint's own status, and dropping not_started from MINT_AWAITS_CONSENT_STATUSES. scripts/mutation-register.test.ts is the static half run by the ordinary suite: every entry still locates its expression, the mutant parses, and its suites exist. No production file changed. Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + scripts/mutation-register-preload.ts | 35 +++ scripts/mutation-register.test.ts | 94 ++++++ scripts/mutation-register.ts | 431 +++++++++++++++++++++++++++ 4 files changed, 561 insertions(+) create mode 100644 scripts/mutation-register-preload.ts create mode 100644 scripts/mutation-register.test.ts create mode 100644 scripts/mutation-register.ts diff --git a/package.json b/package.json index 442e3a3d3..b2134adb5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "typecheck:tests": "bun scripts/typecheck-tests.ts", "test:ci": "bun scripts/test-ci.ts", "test:server:sharded": "bun scripts/test-server-sharded.ts", + "test:mutations": "bun scripts/mutation-register.ts", "pretest": "bun run generate:app-config", "test:smoke": "OPENBOT_SMOKE=1 bun test tests/smoke", "test:live-screen": "OPENBOT_LIVE_SCREEN=1 bun test agent-computer/tests/live-screen.test.ts agent-computer/tests/browser-close-announcement.test.ts agent-computer/tests/reset-run.test.ts", diff --git a/scripts/mutation-register-preload.ts b/scripts/mutation-register-preload.ts new file mode 100644 index 000000000..f98ac4658 --- /dev/null +++ b/scripts/mutation-register-preload.ts @@ -0,0 +1,35 @@ +// Applies ONE mutation-register entry to a module's source as bun imports it. See +// `scripts/mutation-register.ts`, which spawns `bun test --preload` with this file. +// +// IN MEMORY, NEVER ON DISK. The loader hands bun the rewritten source; the file itself is never +// opened for writing, so a crashed, timed-out or interrupted run leaves the tree exactly as it was. +// +// The mark file is how the runner tells "the suite caught the mutation" apart from "the suite never +// imported the mutated file": it is written only when the rewrite actually happened. + +import { plugin } from "bun"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { mutate, REGISTER, REPO_ROOT } from "./mutation-register"; + +const id = process.env.MUTATION_REGISTER_ENTRY; +const mark = process.env.MUTATION_REGISTER_MARK; +const entry = REGISTER.find((e) => e.id === id); +if (!entry || !mark) { + throw new Error( + `mutation-register-preload: no register entry named ${JSON.stringify(id)}, or no mark path. Run it through scripts/mutation-register.ts.`, + ); +} +const target = join(REPO_ROOT, entry.path); +const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +plugin({ + name: `mutation-register:${entry.id}`, + setup(build) { + build.onLoad({ filter: new RegExp(`^${escaped}$`) }, (args) => { + const contents = mutate(readFileSync(args.path, "utf8"), entry); + writeFileSync(mark, entry.id); + return { contents, loader: args.path.endsWith(".tsx") ? "tsx" : "ts" }; + }); + }, +}); diff --git a/scripts/mutation-register.test.ts b/scripts/mutation-register.test.ts new file mode 100644 index 000000000..3eeb0226c --- /dev/null +++ b/scripts/mutation-register.test.ts @@ -0,0 +1,94 @@ +// The register's STATIC half, cheap enough for every test run: every entry still finds the exact +// expression it guards, its mutant still parses, and its named suites exist. Running the mutants +// themselves is `bun run test:mutations` (scripts/mutation-register.ts) — that is the half that +// proves the suites go red, and it spawns one `bun test` per entry, so it is not run from here. +// +// Why bother with this half: an entry whose expression was refactored away fails `test:mutations` +// loudly, but only when somebody runs it. This makes the same drift fail the ordinary suite, on the +// change that caused it, while the person who moved the code still knows what it was. + +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + ANCHOR_WINDOW, + locate, + type MutationEntry, + mutate, + REGISTER, + REPO_ROOT, +} from "./mutation-register"; + +const transpilers = { + ts: new Bun.Transpiler({ loader: "ts" }), + tsx: new Bun.Transpiler({ loader: "tsx" }), +}; + +describe("every register entry", () => { + test("has a unique id", () => { + const ids = REGISTER.map((e) => e.id); + expect(ids.filter((id, i) => ids.indexOf(id) !== i)).toEqual([]); + }); + + for (const entry of REGISTER) { + test(`${entry.id}: locates its quoted expression, and its mutant parses`, () => { + const source = readFileSync(join(REPO_ROOT, entry.path), "utf8"); + expect(locate(source, entry)).not.toHaveProperty("error"); + const mutant = mutate(source, entry); + expect(mutant).not.toBe(source); + // A syntax error would make every named test "fail" without telling any distinction apart. + const transpiler = entry.path.endsWith(".tsx") + ? transpilers.tsx + : transpilers.ts; + expect(() => transpiler.transformSync(source)).not.toThrow(); + expect(() => transpiler.transformSync(mutant)).not.toThrow(); + }); + + test(`${entry.id}: names 1-3 test files that exist`, () => { + expect(entry.tests.length).toBeGreaterThanOrEqual(1); + expect(entry.tests.length).toBeLessThanOrEqual(3); + for (const file of entry.tests) { + expect(existsSync(join(REPO_ROOT, file))).toBe(true); + } + }); + } +}); + +describe("locate", () => { + const entry = (over: Partial): MutationEntry => ({ + id: "x", + kind: "identity", + path: "f.ts", + find: "a.vendorUserId", + replace: "a.userId", + tests: ["t.test.ts"], + why: "", + ...over, + }); + + test("refuses a bare expression that occurs twice, rather than picking one", () => { + const source = "f(a.vendorUserId);\ng(a.vendorUserId);"; + expect(locate(source, entry({}))).toHaveProperty("error"); + }); + + test("an anchor picks the occurrence after it", () => { + const source = "f(a.vendorUserId);\nfunction g() { h(a.vendorUserId); }"; + const found = locate(source, entry({ anchor: "function g() {" })); + expect(found).toEqual({ index: source.lastIndexOf("a.vendorUserId") }); + }); + + test("does not re-aim at a sibling past the window when the guarded line is deleted", () => { + // The guarded expression after `function g() {` is gone; the next occurrence belongs to `k`. + const source = `function g() { h(); }\n${" ".repeat(ANCHOR_WINDOW)}\nfunction k() { h(a.vendorUserId); }`; + expect(locate(source, entry({ anchor: "function g() {" }))).toHaveProperty( + "error", + ); + }); + + test("refuses an anchor that is not unique", () => { + const source = "function g() { a.vendorUserId }\nfunction g() {}"; + expect(locate(source, entry({ anchor: "function g() {" }))).toHaveProperty( + "error", + ); + }); +}); diff --git a/scripts/mutation-register.ts b/scripts/mutation-register.ts new file mode 100644 index 000000000..6fa356d31 --- /dev/null +++ b/scripts/mutation-register.ts @@ -0,0 +1,431 @@ +#!/usr/bin/env bun +// The mutation register: known defects, planted on purpose, that named suites MUST catch. +// +// WHY THIS EXISTS. The defect class this repo's review cycles found most often is not a wrong line; +// it is a suite that cannot tell two things apart that the code must keep apart — a local id and a +// vendor id, a folded and an unfolded status, an absent field and an unreadable one, a scoped query +// and an unscoped one, a call made and a call silently not made. A suite like that stays green when +// the distinction is broken, so nothing fails and nothing changes. Each review wave checked this by +// hand — "five planted identity defects passed 245/245" — and the check was never recorded, so the +// next fake or fixture could re-collapse the distinction without anyone noticing. This file is that +// hand check, written down, so it can be re-run by anyone at any time. +// +// WHAT A RUN DOES, per entry: +// 1. Locates the entry's quoted expression in the target file (see "KEYED BY QUOTED EXPRESSION"). +// 2. Runs the entry's test files UNMUTATED, once per distinct file set. They must pass: a mutant +// "caught" by a suite that was already red proves nothing. +// 3. Runs the same files with the mutation applied IN MEMORY by `mutation-register-preload.ts`, +// a bun loader plugin that rewrites the module's source as it is imported. The file on disk is +// never written, so a crash, a timeout or a Ctrl-C cannot leave the tree mutated. The preload +// leaves a mark when it actually rewrote the module; no mark means the named suites never +// imported the file, which is the register being wrong, and it fails as such. +// 4. A mutant is KILLED when the run reports at least one `(fail)` test. Only a failing TEST +// counts — an import error or a crash is not a suite telling the distinction apart. +// +// THE VERDICT, and what each failure means: +// - an entry with no `survivesUntil` whose mutant SURVIVES → a coverage hole. The named suite +// cannot see this distinction any more. Fix the suite, not the register. +// - an entry WITH `survivesUntil` whose mutant is KILLED → the debt is paid. Delete the +// `survivesUntil` field so the entry starts guarding. +// - an expression that cannot be located → the guarded code changed. Re-quote it deliberately. +// +// `survivesUntil` IS A RECORD OF DEBT, the same discipline as `server/tests/typecheck-baseline.txt`: +// every entry carrying it is a real defect the suite cannot see today, named by the finding that +// owns it. Removing one because the suite now catches the mutant is always correct. ADDING one to +// an entry that was guarding is not — that is a suite losing coverage, and this check exists to say +// so. A new entry may land with `survivesUntil` only when the hole it records is pre-existing and +// has a finding id. +// +// KEYED BY QUOTED EXPRESSION, NEVER BY `path:line`. A line-keyed list goes red when someone rewraps +// a comment three functions above the guarded code, and people learn to re-baseline it without +// reading (`assertion-shape.test.ts`'s ALLOW list collided in three consecutive waves for exactly +// this reason; `sole-definition.test.ts`, keyed by path and quoted expression, never has). An entry +// names the expression itself. When the expression appears more than once in its file, `anchor` +// names a second quoted expression that occurs EXACTLY ONCE, and the mutated expression is its first +// occurrence after the anchor, within {@link ANCHOR_WINDOW} characters — so deleting the guarded +// line cannot silently re-aim the mutation at a sibling further down the file. +// +// Usage: +// bun run test:mutations # run every entry; exit 1 on any failure above +// bun run test:mutations [ ...] # run only the named entries +// bun run test:mutations --list # print the register without running anything +// +// Suites that need a database read TEST_DATABASE_URL from the environment, as they always do. + +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** How far past its anchor an expression may start and still be the one the entry means. */ +export const ANCHOR_WINDOW = 600; + +/** + * The five ways a distinction collapses. Every entry is one of them. `casing` covers every + * normalization of a vendor word before it is compared: case, padding, and which words the + * comparison accepts at all. + */ +export type DistinctionKind = + | "identity" + | "casing" + | "absent-vs-unreadable" + | "query-scope" + | "call-made"; + +export interface MutationEntry { + /** Stable name, used on the command line and in the report. */ + id: string; + kind: DistinctionKind; + /** Repo-relative path of the production file the mutation applies to. */ + path: string; + /** Optional quoted expression occurring exactly once, used to locate a non-unique `find`. */ + anchor?: string; + /** The exact quoted expression the mutation replaces. */ + find: string; + /** What it is replaced with. Must still parse: a syntax error is not a kill. */ + replace: string; + /** Repo-relative test files, 1-3, that must go red under the mutation. */ + tests: string[]; + /** Why this mutation matters, and the watched red it is based on. */ + why: string; + /** Finding id that owns a KNOWN hole: the mutant is expected to survive until it is fixed. */ + survivesUntil?: string; +} + +export const REGISTER: MutationEntry[] = [ + { + id: "composio-revoke-asks-under-local-id", + kind: "identity", + path: "server/src/plugins/composio-adapter.ts", + anchor: "async revoke({ account, toolkit }): Promise {", + find: "const userId = account.vendorUserId;", + replace: "const userId = account.userId;", + tests: ["server/tests/composio-adapter.test.ts"], + why: "A8a: withdrawal must ask Composio under the vendor id. Passed 245/245 before A8a; 18 fail after.", + }, + { + id: "composio-is-connected-asks-under-local-id", + kind: "identity", + path: "server/src/plugins/composio-adapter.ts", + anchor: "async isConnected({ account, toolkit }): Promise {", + find: "const userId = account.vendorUserId;", + replace: "const userId = account.userId;", + tests: ["server/tests/composio-adapter.test.ts"], + why: "A8a: the gate ./access asks before running an action must ask under the vendor id. 0 fail before A8a; 3 after.", + }, + { + id: "arcade-connection-status-unfolded", + kind: "casing", + path: "server/src/plugins/arcade-adapter.ts", + find: "const status = textOf(row.connection_status)?.trim().toLowerCase();", + replace: "const status = textOf(row.connection_status);", + tests: [ + "server/tests/arcade-adapter-connections.test.ts", + "server/tests/arcade-adapter-status.test.ts", + ], + why: "A vendor-side change of case or padding on connection_status must not change whether somebody is shown as connected.", + }, + { + id: "arcade-mint-status-unfolded", + kind: "casing", + path: "server/src/plugins/arcade-adapter.ts", + find: "const status = textOf(answer.status)?.trim().toLowerCase();", + replace: "const status = textOf(answer.status);", + tests: ["server/tests/arcade-authorize-status-engine.test.ts"], + why: "D6: deleting the fold on the mint's own status was reported to leave arcade-authorize-status-engine green, because :118 asserts only BrokerRefusalError, which the allowlist yields anyway.", + survivesUntil: "D6", + }, + { + id: "arcade-mint-not-started-dropped", + kind: "casing", + path: "server/src/plugins/arcade-adapter.ts", + find: 'const MINT_AWAITS_CONSENT_STATUSES = new Set(["not_started", "pending"]);', + replace: 'const MINT_AWAITS_CONSENT_STATUSES = new Set(["pending"]);', + tests: ["server/tests/arcade-authorize-status-engine.test.ts"], + why: "D6: not_started, half the allowlist, is untested — a mint that has not begun would be refused as if it were finished.", + survivesUntil: "D6", + }, + { + id: "arcade-unreadable-authorization-read-as-none", + kind: "absent-vs-unreadable", + path: "server/src/plugins/arcade-adapter.ts", + anchor: + 'if (typeof authorization !== "object" || Array.isArray(authorization)) {', + find: 'kind: "unreadable",', + replace: 'kind: "none",', + tests: [ + "server/tests/arcade-adapter-status.test.ts", + "server/tests/arcade-auth-provider.test.ts", + "server/tests/arcade-adapter-connections.test.ts", + ], + why: "C2/C2b: an authorization block that cannot be read is not the vendor saying none — reading it as none silently drops the consent requirement.", + }, + { + id: "arcade-auth-providers-skip-unreadable", + kind: "absent-vs-unreadable", + path: "server/src/plugins/arcade-adapter.ts", + find: "unreadable ??= { action, field: read.field, shape: read.shape };", + replace: "void action;", + tests: [ + "server/tests/arcade-adapter-connections.test.ts", + "server/tests/arcade-auth-provider.test.ts", + ], + why: "C2b: restoring the silent skip of an unreadable authorization in authProvidersNamedBy turned 3 refusals back into answers (35 pass / 3 fail).", + }, + { + id: "composio-account-listing-unscoped-by-person", + kind: "query-scope", + path: "server/src/plugins/composio-adapter.ts", + find: "userIds: [userId],", + replace: "", + tests: ["server/tests/composio-adapter.test.ts"], + why: "A8a: an account listing without its userIds filter reads everybody's accounts as this person's. 5 fail before A8a's honest fakes; 23 after.", + }, + { + id: "arcade-connection-listing-unscoped-by-person", + kind: "query-scope", + path: "server/src/plugins/arcade-adapter.ts", + anchor: "client.admin.userConnections.list({", + find: "user_id: vendorUserId,", + replace: "", + tests: [ + "server/tests/arcade-adapter-connections.test.ts", + "server/tests/arcade-adapter.test.ts", + ], + why: "A6b: the connection listing must be scoped to this person, or somebody else's connection answers isConnected and is what revoke deletes.", + }, + { + id: "disconnect-never-asks-the-vendor", + kind: "call-made", + path: "server/src/plugins/store.ts", + find: "const ended = await broker.revoke({", + replace: + "const ended = await ((_skipped: unknown) => Promise.resolve(false))({", + tests: [ + "server/tests/arcade-disconnect-route.test.ts", + "server/tests/arcade-disconnect-cascade.test.ts", + ], + why: "C8c: a disconnect that never asks the vendor to withdraw leaves a live account behind a deleted local row.", + }, +]; + +/** Where in `source` the entry's expression starts, or a sentence saying why it cannot be found. */ +export function locate( + source: string, + entry: MutationEntry, +): { index: number } | { error: string } { + const count = (needle: string) => source.split(needle).length - 1; + if (entry.anchor === undefined) { + const n = count(entry.find); + if (n === 1) return { index: source.indexOf(entry.find) }; + return { + error: + n === 0 + ? `${entry.path} no longer contains ${JSON.stringify(entry.find)}. The guarded code changed; re-quote the entry deliberately.` + : `${entry.path} contains ${JSON.stringify(entry.find)} ${n} times. Give the entry an anchor that occurs exactly once.`, + }; + } + const anchors = count(entry.anchor); + if (anchors !== 1) { + return { + error: `${entry.path} contains the anchor ${JSON.stringify(entry.anchor)} ${anchors} times; it must occur exactly once.`, + }; + } + const after = source.indexOf(entry.anchor) + entry.anchor.length; + const index = source.indexOf(entry.find, after); + if (index === -1 || index - after > ANCHOR_WINDOW) { + return { + error: `${entry.path} has no ${JSON.stringify(entry.find)} within ${ANCHOR_WINDOW} characters after ${JSON.stringify(entry.anchor)}. The guarded code changed; re-quote the entry deliberately.`, + }; + } + return { index }; +} + +/** `source` with the entry's mutation applied. Throws when the expression cannot be located. */ +export function mutate(source: string, entry: MutationEntry): string { + const found = locate(source, entry); + if ("error" in found) throw new Error(found.error); + return ( + source.slice(0, found.index) + + entry.replace + + source.slice(found.index + entry.find.length) + ); +} + +interface Run { + exitCode: number; + failed: number; + failNames: string[]; + output: string; +} + +const RUN_TIMEOUT_MS = 300_000; + +function runTests(files: string[], env: Record): Run { + const preload = join(REPO_ROOT, "scripts", "mutation-register-preload.ts"); + const args = ["bun", "test"]; + if (env.MUTATION_REGISTER_ENTRY) args.push("--preload", preload); + const proc = Bun.spawnSync([...args, ...files], { + cwd: REPO_ROOT, + env: { ...process.env, ...env }, + stdout: "pipe", + stderr: "pipe", + timeout: RUN_TIMEOUT_MS, + }); + const output = `${proc.stdout.toString()}\n${proc.stderr.toString()}`; + const failNames = output + .split("\n") + .filter((line) => line.startsWith("(fail)")) + .map((line) => + line.replace(/^\(fail\)\s*/, "").replace(/\s*\[[^\]]*\]$/, ""), + ); + return { + exitCode: proc.exitCode ?? -1, + failed: failNames.length, + failNames, + output, + }; +} + +type Verdict = + | "killed" + | "survived" + | "not-reached" + | "unlocatable" + | "control-red"; + +interface Outcome { + entry: MutationEntry; + verdict: Verdict; + ok: boolean; + detail: string; +} + +function judge( + entry: MutationEntry, + verdict: Verdict, + detail: string, +): Outcome { + if (verdict === "killed") { + return entry.survivesUntil + ? { + entry, + verdict, + ok: false, + detail: `${detail}. STALE DEBT: the suite now catches this; delete survivesUntil "${entry.survivesUntil}".`, + } + : { entry, verdict, ok: true, detail }; + } + if (verdict === "survived" && entry.survivesUntil) { + return { + entry, + verdict, + ok: true, + detail: `known hole, owned by ${entry.survivesUntil}`, + }; + } + return { entry, verdict, ok: false, detail }; +} + +function main(argv: string[]): number { + if (argv.includes("--list")) { + for (const e of REGISTER) { + console.log( + `${e.id} [${e.kind}] ${e.path}${e.survivesUntil ? ` survivesUntil ${e.survivesUntil}` : ""}`, + ); + } + return 0; + } + const wanted = argv.filter((a) => !a.startsWith("--")); + const unknown = wanted.filter((id) => !REGISTER.some((e) => e.id === id)); + if (unknown.length > 0) { + console.error(`No register entry named ${unknown.join(", ")}.`); + return 1; + } + const entries = wanted.length + ? REGISTER.filter((e) => wanted.includes(e.id)) + : REGISTER; + + const controls = new Map(); + const outcomes: Outcome[] = []; + const markDir = mkdtempSync(join(tmpdir(), "mutation-register-")); + try { + for (const entry of entries) { + const target = join(REPO_ROOT, entry.path); + const before = readFileSync(target, "utf8"); + const found = locate(before, entry); + if ("error" in found) { + outcomes.push(judge(entry, "unlocatable", found.error)); + continue; + } + const key = [...entry.tests].sort().join(" "); + let control = controls.get(key); + if (!control) { + process.stdout.write(`control ${key} ... `); + control = runTests(entry.tests, {}); + controls.set(key, control); + console.log(control.exitCode === 0 ? "green" : "RED"); + } + if (control.exitCode !== 0) { + outcomes.push( + judge( + entry, + "control-red", + `the named suites fail WITHOUT the mutation (${control.failed} failing), so a kill would prove nothing:\n${control.failNames.join("\n")}`, + ), + ); + continue; + } + const mark = join(markDir, entry.id); + process.stdout.write(`mutant ${entry.id} ... `); + const run = runTests(entry.tests, { + MUTATION_REGISTER_ENTRY: entry.id, + MUTATION_REGISTER_MARK: mark, + }); + if (readFileSync(target, "utf8") !== before) { + // The preload never writes, so this is a bug in the register itself. Say so loudly. + throw new Error( + `${entry.path} changed on disk during the mutant run. Inspect it with git diff.`, + ); + } + let outcome: Outcome; + if (!existsSync(mark)) { + outcome = judge( + entry, + "not-reached", + `the named suites never imported ${entry.path}, so the mutation was never applied. Name a suite that exercises it.\n${run.output.slice(-2000)}`, + ); + } else if (run.failed > 0) { + outcome = judge(entry, "killed", `${run.failed} test(s) failed`); + } else { + outcome = judge( + entry, + "survived", + run.exitCode === 0 + ? "every named test still passes with the distinction broken: COVERAGE HOLE" + : `no test failed (exit ${run.exitCode}); a crash is not a kill:\n${run.output.slice(-2000)}`, + ); + } + console.log(outcome.verdict.toUpperCase()); + outcomes.push(outcome); + } + } finally { + rmSync(markDir, { recursive: true, force: true }); + } + + console.log(""); + for (const o of outcomes) { + console.log( + `${o.ok ? "ok " : "FAIL"} ${o.verdict.padEnd(11)} ${o.entry.id} — ${o.detail}`, + ); + } + const failures = outcomes.filter((o) => !o.ok).length; + console.log( + `\n${outcomes.length - failures} ok, ${failures} failing, of ${outcomes.length} entries.`, + ); + return failures === 0 ? 0 : 1; +} + +if (import.meta.main) process.exit(main(process.argv.slice(2))); From d161617f120e1e7bf4fe375b80aa3c90b369ca4d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:07:57 +0200 Subject: [PATCH 210/232] test(arcade): L2 contract table over how every vendor-field reader treats an unreadable value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convergence lever L2 for class K2 (fail-open reads on authorization decisions). Strictly additive: one new test file, no production change, no export added — every reader is driven through its public door (broker.grantsFor, broker.listApps, transport listTools, broker.isConnected, broker.authorize) over SDK prototype stubs. Rows are keyed `reader (file) · vendor field path · shape`, never path:line. Each row carries the RIGHT answer (`want`). Rows production answers wrongly today are recorded in KNOWN_FAIL_OPEN with their current answer and owning finding (A7c, C2c, A7b), on typecheck-baseline.txt's discipline: equality both ways, so a fixed reader fails until its debt entry is deleted, and a debt row that drifts to a third answer fails too. The K2 fix wave deletes entries; it never edits a `want`. 94 rows: 72 hostile-shape (9 reader-fields x 8 shapes), 6 readable positive controls, 8 normalization/allowlist rows (incl. D6's `not_started` and ` Pending `), 8 meta checks. 43 rows are debt, 43 are enforced (measured). Co-Authored-By: Claude Opus 5.5 --- .../tests/arcade-vendor-read-contract.test.ts | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 server/tests/arcade-vendor-read-contract.test.ts diff --git a/server/tests/arcade-vendor-read-contract.test.ts b/server/tests/arcade-vendor-read-contract.test.ts new file mode 100644 index 000000000..c32f84a96 --- /dev/null +++ b/server/tests/arcade-vendor-read-contract.test.ts @@ -0,0 +1,718 @@ +// server/tests/arcade-vendor-read-contract.test.ts +/** + * HOW EVERY ARCADE VENDOR-FIELD READER TREATS A VALUE IT CANNOT READ — one row per reader × shape. + * + * The class this guards is K2: a reader on an AUTHORIZATION decision that fails OPEN. C2 gave the + * `requirements.authorization` block one tri-state reading, `authorizationRequirementOf` — + * `none | unreadable(field, shape) | required` — and C2b held `authProvidersNamedBy` to it. The + * sibling readers never got it: each picked its own default for a field it could not read, and + * the default it picked was the permissive one. A missing `token_status` reads as authorized (A7c), + * an unreadable scopes block reads as "the connection is the grant" (C2c), a `connection_status` + * that is present but not text reads as live (A7b), and the mint's allowlist test could not fail + * on a missing `.trim().toLowerCase()` (D6). The tests never presented the shapes that show it. + * + * THE RULE THE TABLE STATES. Absent (`missing`, `undefined`, and `null` where the vendor uses it + * for absence) reads as the field's documented default. A value that is PRESENT BUT NOT THE SHAPE + * THE FIELD HAS is unreadable, and unreadable FAILS CLOSED: the grants screen says `unknown`, a + * listing refuses, a connection is not live. Never authorized, never live, never `[]`-as-grant. + * + * EACH ROW IS DRIVEN THROUGH THE READER'S PUBLIC DOOR — `broker.grantsFor`, `broker.listApps`, + * the transport's `listTools`, `broker.isConnected`, `broker.authorize` — over SDK prototype + * stubs, so nothing here needs a production export and the verdict is the one a caller sees. + * + * ROWS ARE KEYED `reader · field · shape`, never `path:line`. The reader is a binding name checked + * below to still exist in its file; the field is the vendor's own path. Neither moves when + * somebody rewraps a comment, which is the collision a line-keyed list has had three waves running. + * + * KNOWN-WRONG ROWS ARE DEBT, NOT SPECIFICATION — the discipline of `typecheck-baseline.txt`. + * Every row's `want` is the RIGHT answer. Where production answers something else today, the row + * is listed in {@link KNOWN_FAIL_OPEN} with what it answers NOW and the finding that owns it. The + * check is equality both ways: an enforced row must answer `want`; a debt row must answer exactly + * its recorded `now` — so it can neither drift to a third answer unseen nor be FIXED without its + * entry being deleted. Fixing a reader and deleting its entries is always correct. ADDING an entry + * is not: a new entry means a reader that fails open was written today, and the gate exists to + * say so. Editing a `want` to match what production does is the one move this file forbids. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import Arcade from "@arcadeai/arcadejs"; +import { listTools, useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +// --------------------------------------------------------------------------------------------- +// Harness: SDK prototype stubs, installed BEFORE construction; `new Arcade({ apiKey })` dials +// nothing. +// --------------------------------------------------------------------------------------------- + +type Methods = Record unknown>; +const protoOf = (pick: (c: Arcade) => unknown): Methods => + Object.getPrototypeOf( + pick(new Arcade({ apiKey: "never-dialled" })), + ) as Methods; + +const restores: (() => void)[] = []; +function stub( + pick: (c: Arcade) => unknown, + name: string, + impl: (...args: unknown[]) => unknown, +) { + const on = protoOf(pick); + const was = on[name]; + on[name] = impl; + restores.push(() => { + on[name] = was; + }); +} +afterEach(() => { + while (restores.length) restores.pop()?.(); + useArcadeClient(null); +}); + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +/** + * What a call answered, or the fact that it refused. + * + * A CRASH IS NOT A REFUSAL. A `TypeError` off a hole the reader dereferenced is reported as + * `crashes`, so a row whose right answer is `refuses` cannot be satisfied by the reader falling + * over — nor by this harness doing so. + */ +async function settle( + run: () => Promise, + read: (value: T) => string, +): Promise { + let value: T; + try { + value = await run(); + } catch (error) { + return error instanceof TypeError || error instanceof ReferenceError + ? `crashes: ${(error as Error).message}` + : "refuses"; + } + return read(value); +} + +// --------------------------------------------------------------------------------------------- +// The shapes. `missing` deletes the key; every other shape sets it. +// --------------------------------------------------------------------------------------------- + +const MISSING = Symbol("missing"); +const SHAPES: { label: string; value: unknown }[] = [ + { label: "missing", value: MISSING }, + { label: "undefined", value: undefined }, + { label: "null", value: null }, + { label: "{}", value: {} }, + { label: "false", value: false }, + { label: "0", value: 0 }, + { label: "[]", value: [] }, + { label: '""', value: "" }, +]; + +/** `base` with `value` placed at `path` (or the last key deleted, for `MISSING`). */ +function placed( + base: Record, + path: string[], + value: unknown, +): Record { + const root = structuredClone(base); + let at = root; + for (const key of path.slice(0, -1)) { + at = at[key] as Record; + } + const last = path[path.length - 1]; + if (value === MISSING) delete at[last]; + else at[last] = value; + return root; +} + +// --------------------------------------------------------------------------------------------- +// The readers, each through its public door. `observe` turns a vendor value at the reader's field +// into a VERDICT string; the vocabulary is per reader and spelled out on each. +// --------------------------------------------------------------------------------------------- + +type Reader = { + /** The binding that does the read — checked below to still be defined in `file`. */ + name: string; + file: "server/src/plugins/arcade-adapter.ts" | "server/src/plugins/arcade.ts"; + /** The door the verdict is read through. */ + door: string; + observe: (edit: Edit) => Promise; +}; + +/** What a row does to the reader's base object: place one hostile value (and maybe a neighbour). */ +type Edit = (base: Record) => Record; + +/** A grant read as ` []`, or `unknown`/`needs-operator`, or `refuses`. */ +function grantVerdict(grants: { state: string; scopes?: string[] }[]): string { + if (grants.length !== 1) return `${grants.length} grants`; + const [grant] = grants; + return grant.scopes === undefined + ? grant.state + : `${grant.state} [${grant.scopes.join(",")}]`; +} + +/** The listing row the grants and catalogue readers see, with a held, scoped authorization. */ +const LISTED = { + qualified_name: "Github.SetStarred", + fully_qualified_name: "Github.SetStarred@1.0.0", + name: "SetStarred", + toolkit: { name: "Github", description: "" }, + metadata: { behavior: { read_only: true, destructive: false } }, + requirements: { + met: true, + secrets: [], + authorization: { + provider_id: "github", + status: "active", + token_status: "completed", + oauth2: { scopes: ["repo"] }, + }, + }, +}; + +const GRANT_STATE_OF: Reader = { + name: "grantStateOf", + file: "server/src/plugins/arcade-adapter.ts", + door: "broker.grantsFor", + observe: (edit) => { + const item = edit(LISTED); + stub( + (c) => c.tools, + "list", + () => Promise.resolve({ items: [item] }), + ); + return settle( + () => + createArcadeClient("never-dialled").broker.grantsFor({ + toolkit: "Github", + account, + }), + grantVerdict, + ); + }, +}; + +/** The one app's scope standing: `scoped` / `unscoped`, or `refuses`. */ +const CATALOGUE_SCOPES_OF: Reader = { + name: "scopesOf", + file: "server/src/plugins/arcade-adapter.ts", + door: "broker.listApps", + observe: (edit) => { + const item = edit(LISTED); + stub( + (c) => c.tools, + "list", + () => Promise.resolve({ items: [item], total_count: 1 }), + ); + return settle( + () => createArcadeClient("never-dialled").broker.listApps(), + (apps) => { + if (apps.length !== 1) return `${apps.length} apps`; + return (apps[0].scopedActionCount ?? 0) > 0 ? "scoped" : "unscoped"; + }, + ); + }, +}; + +/** The listed tool's `scopes` as `scopes []`, or `refuses`. */ +const TRANSPORT_SCOPES_OF: Reader = { + name: "scopesOf", + file: "server/src/plugins/arcade.ts", + door: "listTools", + observe: (edit) => { + const item = edit(LISTED); + useArcadeClient({ + listTools: () => Promise.resolve({ items: [item] }), + execute: () => Promise.reject(new Error("not under test")), + }); + return settle( + () => listTools({ url: "arcade://Github" }), + (tools) => + tools.length === 1 + ? `scopes [${tools[0].scopes.join(",")}]` + : `${tools.length} tools`, + ); + }, +}; + +/** The connection: `live` / `not-live`, or `refuses`. */ +const CONNECTION_IS_LIVE: Reader = { + name: "connectionIsLive", + file: "server/src/plugins/arcade-adapter.ts", + door: "broker.isConnected", + observe: (edit) => { + const row = edit({ + id: "at_1", + connection_id: "ac_1", + provider_id: "cpk-poc", + connection_status: "active", + provider_user_info: { name: "Ada" }, + }); + stub( + (c) => c.tools, + "list", + () => + Promise.resolve({ + items: [ + { + qualified_name: "Github.A", + requirements: { authorization: { provider_id: "github" } }, + }, + ], + total_count: 1, + }), + ); + stub( + (c) => c.admin.authProviders, + "list", + () => + Promise.resolve({ + items: [{ id: "cpk-poc", provider_id: "github" }], + total_count: 1, + }), + ); + stub( + (c) => c.admin.userConnections, + "list", + (query) => + Promise.resolve( + (query as { user_id?: unknown }).user_id === account.vendorUserId + ? { items: [row], total_count: 1 } + : { items: [], total_count: 0 }, + ), + ); + return settle( + () => + createArcadeClient("never-dialled").broker.isConnected({ + account, + toolkit: "Github", + }), + (live) => (live ? "live" : "not-live"), + ); + }, +}; + +/** The mint: `consent-next` when handed on, `refuses: already` for a held grant, else `refuses`. */ +const MINT_STATUS: Reader = { + name: "MINT_AWAITS_CONSENT_STATUSES", + file: "server/src/plugins/arcade-adapter.ts", + door: "broker.authorize", + observe: async (edit) => { + const answer = edit({ + id: "ar_mint1", + url: "https://consent.example/go", + status: "pending", + }); + stub( + (c) => c.tools, + "authorize", + () => Promise.resolve(answer), + ); + try { + const minted = await createArcadeClient("never-dialled").broker.authorize( + { + account, + toolkit: "Gmail", + action: "Gmail.SendEmail", + returnUrl: "https://openbot.example/settings/plugins", + }, + ); + return minted.redirectUrl === "https://consent.example/go" + ? "consent-next" + : "handed on elsewhere"; + } catch (error) { + if (error instanceof TypeError) return `crashes: ${error.message}`; + return /already/i.test(String((error as Error).message)) + ? "refuses: already" + : "refuses"; + } + }, +}; + +// --------------------------------------------------------------------------------------------- +// The table. +// --------------------------------------------------------------------------------------------- + +type Row = { + reader: Reader; + /** The vendor field the reader reads, as a path from the object the door hands it. */ + field: string[]; + /** The shape label, or for a normalization row the quoted text. */ + shape: string; + value: unknown; + /** A neighbouring field set first, so a fallback through it cannot answer the row. */ + beside?: { path: string[]; value: unknown }; + /** THE RIGHT ANSWER. Never edited to match production; see the header. */ + want: string; + why: string; +}; + +const keyOf = (row: Row): string => + `${row.reader.name} (${row.reader.file.split("/").pop()}) · ${row.field.join(".")} · ${row.shape}`; + +/** Absent → `absent`; the rest → `unreadable`. Labels as {@link SHAPES} spells them. */ +function sweep( + reader: Reader, + field: string[], + answers: { + absent: string; + unreadable: string; + /** Shapes that are READABLE at this field and so answer `absent`, not `unreadable`. */ + readableAsAbsent?: string[]; + }, + why: string, +): Row[] { + const absentShapes = new Set([ + "missing", + "undefined", + "null", + ...(answers.readableAsAbsent ?? []), + ]); + return SHAPES.map(({ label, value }) => ({ + reader, + field, + shape: label, + value, + want: absentShapes.has(label) ? answers.absent : answers.unreadable, + why, + })); +} + +const AUTH = ["requirements", "authorization"]; +const BESIDE_INACTIVE = '" Completed " beside status "inactive"'; + +const ROWS: Row[] = [ + // grantStateOf — token_status, beside `status: "active"` (the fallback A7c names). `status` is + // the auth PROVIDER's standing, not this account's grant, so no token_status is no answer. + ...sweep( + GRANT_STATE_OF, + [...AUTH, "token_status"], + { absent: "unknown", unreadable: "unknown" }, + "what this account holds is token_status; nothing readable there is not a grant", + ), + // grantStateOf — the scopes the grant covers, beside `token_status: "completed"`. + ...sweep( + GRANT_STATE_OF, + [...AUTH, "oauth2", "scopes"], + { + absent: "authorized []", + unreadable: "unknown", + readableAsAbsent: ["[]"], + }, + "an unreadable scope list is not 'the connection is the grant'", + ), + ...sweep( + GRANT_STATE_OF, + [...AUTH, "oauth2"], + { + absent: "authorized []", + unreadable: "unknown", + readableAsAbsent: ["{}"], + }, + "an oauth2 block that is not an object is not 'no scopes'", + ), + // scopesOf (adapter) — feeds listApps' scopedActionCount/permissionCount. + ...sweep( + CATALOGUE_SCOPES_OF, + [...AUTH, "oauth2", "scopes"], + { absent: "unscoped", unreadable: "refuses", readableAsAbsent: ["[]"] }, + "the catalogue refuses what it cannot read, as requiresAuthorization already does", + ), + ...sweep( + CATALOGUE_SCOPES_OF, + [...AUTH, "oauth2"], + { absent: "unscoped", unreadable: "refuses", readableAsAbsent: ["{}"] }, + "the catalogue refuses what it cannot read, as requiresAuthorization already does", + ), + // scopesOf (arcade.ts) — the tool `scopes` the gate reads; `[]` there means "connection is the + // grant", so an unreadable block must not become it. + ...sweep( + TRANSPORT_SCOPES_OF, + AUTH, + { absent: "scopes []", unreadable: "refuses", readableAsAbsent: ["{}"] }, + "authorizationRequirementOf's reading: absent is none, a non-object is unreadable", + ), + ...sweep( + TRANSPORT_SCOPES_OF, + [...AUTH, "oauth2"], + { absent: "scopes []", unreadable: "refuses", readableAsAbsent: ["{}"] }, + "an oauth2 block that is not an object is not 'no scopes'", + ), + ...sweep( + TRANSPORT_SCOPES_OF, + [...AUTH, "oauth2", "scopes"], + { absent: "scopes []", unreadable: "refuses", readableAsAbsent: ["[]"] }, + "an unreadable scope list is not 'the connection is the grant'", + ), + // connectionIsLive — absent is live ("silence is not a no", documented in the source, and so is + // an empty string); present but not text is the vendor saying something this cannot read. + ...sweep( + CONNECTION_IS_LIVE, + ["connection_status"], + { absent: "live", unreadable: "not-live", readableAsAbsent: ['""'] }, + "present but not text is not 'no status'", + ).map((row) => + // Explicit `null` is a value the vendor SENT on a connection row (A7b), not an omission. + row.shape === "null" ? { ...row, want: "not-live" } : row, + ), + + // Readable controls: the reader still READS. Without these a reader that answered the + // permissive default for everything would pass every absent row above. + ...( + [ + [ + GRANT_STATE_OF, + [...AUTH, "oauth2", "scopes"], + ["repo"], + "authorized [repo]", + ], + [ + GRANT_STATE_OF, + [...AUTH, "token_status"], + "pending", + "needs-consent [repo]", + ], + [CATALOGUE_SCOPES_OF, [...AUTH, "oauth2", "scopes"], ["repo"], "scoped"], + [ + TRANSPORT_SCOPES_OF, + [...AUTH, "oauth2", "scopes"], + ["repo"], + "scopes [repo]", + ], + [CONNECTION_IS_LIVE, ["connection_status"], "active", "live"], + [MINT_STATUS, ["status"], "pending", "consent-next"], + ] as const + ).map( + ([reader, field, value, want]): Row => ({ + reader, + field: [...field], + shape: `readable ${JSON.stringify(value)}`, + value, + want, + why: "a positive control: the readable value is still read", + }), + ), + + // Normalization: case, whitespace, and every allowlist member. + { + reader: GRANT_STATE_OF, + field: [...AUTH, "token_status"], + shape: BESIDE_INACTIVE, + value: " Completed ", + beside: { path: [...AUTH, "status"], value: "inactive" }, + want: "authorized [repo]", + why: "a case or a space the vendor changes must not change an answer about an account", + }, + { + reader: GRANT_STATE_OF, + field: [...AUTH, "token_status"], + shape: '"NOT_STARTED"', + value: "NOT_STARTED", + want: "needs-consent [repo]", + why: "a case or a space the vendor changes must not change an answer about an account", + }, + { + reader: CONNECTION_IS_LIVE, + field: ["connection_status"], + shape: '" ACTIVE "', + value: " ACTIVE ", + want: "live", + why: "folded and trimmed", + }, + { + reader: CONNECTION_IS_LIVE, + field: ["connection_status"], + shape: '"Expired"', + value: "Expired", + want: "not-live", + why: "an allowlist: any spelling but the live one is not live", + }, + { + reader: MINT_STATUS, + field: ["status"], + shape: '"not_started"', + value: "not_started", + want: "consent-next", + why: "half the allowlist, and the one a first-time mint answers (D6)", + }, + { + reader: MINT_STATUS, + field: ["status"], + shape: '" Pending "', + value: " Pending ", + want: "consent-next", + why: "the only status row that FAILS when the trim/lowercase is deleted (D6)", + }, + { + reader: MINT_STATUS, + field: ["status"], + shape: '" NOT_STARTED "', + value: " NOT_STARTED ", + want: "consent-next", + why: "folded and trimmed, on the other allowlist member (D6)", + }, + { + reader: MINT_STATUS, + field: ["status"], + shape: '" Completed "', + value: " Completed ", + want: "refuses: already", + why: "the held-grant sentence, which only a normalized read reaches (D6)", + }, +]; + +// --------------------------------------------------------------------------------------------- +// THE DEBT. Delete an entry when its reader is fixed; never add one. See the header. +// --------------------------------------------------------------------------------------------- + +const A7C = "A7c: missing/unreadable token_status falls back to status"; +const C2C = "C2c: unreadable oauth2/scopes collapses to []"; +const A7B = "A7b: present-but-not-text connection_status reads as live"; +const A7C_CASE = "A7c: grantStateOf skips .trim().toLowerCase()"; + +const KNOWN_FAIL_OPEN: Record = { + // grantStateOf · token_status — every shape authorizes, off the provider's `status: "active"`. + ...Object.fromEntries( + SHAPES.map(({ label }) => [ + `grantStateOf (arcade-adapter.ts) · requirements.authorization.token_status · ${label}`, + { now: "authorized [repo]", finding: A7C }, + ]), + ), + ...Object.fromEntries( + ["{}", "false", "0", '""'].map((label) => [ + `grantStateOf (arcade-adapter.ts) · requirements.authorization.oauth2.scopes · ${label}`, + { now: "authorized []", finding: C2C }, + ]), + ), + ...Object.fromEntries( + ["[]", "false", "0", '""'].map((label) => [ + `grantStateOf (arcade-adapter.ts) · requirements.authorization.oauth2 · ${label}`, + { now: "authorized []", finding: C2C }, + ]), + ), + ...Object.fromEntries( + ["{}", "false", "0", '""'].map((label) => [ + `scopesOf (arcade-adapter.ts) · requirements.authorization.oauth2.scopes · ${label}`, + { now: "unscoped", finding: C2C }, + ]), + ), + ...Object.fromEntries( + ["[]", "false", "0", '""'].map((label) => [ + `scopesOf (arcade-adapter.ts) · requirements.authorization.oauth2 · ${label}`, + { now: "unscoped", finding: C2C }, + ]), + ), + ...Object.fromEntries( + ["[]", "false", "0", '""'].map((label) => [ + `scopesOf (arcade.ts) · requirements.authorization · ${label}`, + { now: "scopes []", finding: C2C }, + ]), + ), + ...Object.fromEntries( + ["[]", "false", "0", '""'].map((label) => [ + `scopesOf (arcade.ts) · requirements.authorization.oauth2 · ${label}`, + { now: "scopes []", finding: C2C }, + ]), + ), + ...Object.fromEntries( + ["{}", "false", "0", '""'].map((label) => [ + `scopesOf (arcade.ts) · requirements.authorization.oauth2.scopes · ${label}`, + { now: "scopes []", finding: C2C }, + ]), + ), + ...Object.fromEntries( + ["null", "{}", "false", "0", "[]"].map((label) => [ + `connectionIsLive (arcade-adapter.ts) · connection_status · ${label}`, + { now: "live", finding: A7B }, + ]), + ), + [`grantStateOf (arcade-adapter.ts) · requirements.authorization.token_status · ${BESIDE_INACTIVE}`]: + { now: "needs-consent [repo]", finding: A7C_CASE }, + 'grantStateOf (arcade-adapter.ts) · requirements.authorization.token_status · "NOT_STARTED"': + { now: "unknown", finding: A7C_CASE }, +}; + +// --------------------------------------------------------------------------------------------- +// The checks. +// --------------------------------------------------------------------------------------------- + +const REPO = join(import.meta.dir, "..", ".."); + +describe("every vendor-field reader is still where the table says", () => { + const readers = [ + ...new Map( + ROWS.map((r) => [`${r.reader.file}#${r.reader.name}`, r.reader]), + ).values(), + ]; + for (const reader of readers) { + test(`${reader.name} is bound in ${reader.file}`, () => { + const source = readFileSync(join(REPO, reader.file), "utf8"); + const binding = new RegExp( + `(?:^|\\n)\\s*(?:export\\s+)?(?:async\\s+)?(?:function|const)\\s+${reader.name}\\b`, + ); + expect(binding.test(source)).toBe(true); + }); + } +}); + +describe("the debt list is honest", () => { + test("every KNOWN_FAIL_OPEN entry names a row in the table", () => { + const keys = new Set(ROWS.map(keyOf)); + expect(Object.keys(KNOWN_FAIL_OPEN).filter((k) => !keys.has(k))).toEqual( + [], + ); + }); + + test("no debt entry records the right answer as its wrong one", () => { + const wants = new Map(ROWS.map((r) => [keyOf(r), r.want])); + expect( + Object.entries(KNOWN_FAIL_OPEN) + .filter(([key, debt]) => wants.get(key) === debt.now) + .map(([key]) => key), + ).toEqual([]); + }); + + test("row keys are unique", () => { + const keys = ROWS.map(keyOf); + expect(keys.length).toBe(new Set(keys).size); + }); +}); + +describe("a vendor field this deployment cannot read fails closed", () => { + for (const row of ROWS) { + const key = keyOf(row); + const debt = KNOWN_FAIL_OPEN[key]; + const label = debt + ? `[debt ${debt.finding.split(":")[0]}] ${key} — answers ${debt.now}, should answer ${row.want}` + : `${key} → ${row.want} (via ${row.reader.door})`; + test(label, async () => { + const observed = await row.reader.observe((base) => + placed( + row.beside ? placed(base, row.beside.path, row.beside.value) : base, + row.field, + row.value, + ), + ); + const expected = debt ? debt.now : row.want; + const diagnosis = + observed === expected + ? "as recorded" + : debt && observed === row.want + ? "FIXED: delete this row's KNOWN_FAIL_OPEN entry" + : debt + ? "debt changed to a third answer" + : "differs from the right answer"; + expect({ key, observed, diagnosis }).toEqual({ + key, + observed: expected, + diagnosis: "as recorded", + }); + }); + } +}); From f9c72c3ccfd48e40171b173c23d45281fdf13f22 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:08:48 +0200 Subject: [PATCH 211/232] =?UTF-8?q?test(sole-definition):=20pin=20K5=20sec?= =?UTF-8?q?ond=20spellings=20=E2=80=94=20journal,=20sibling=20walk,=20key-?= =?UTF-8?q?app=20answer=20(lever=20L5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strictly additive: new check entries in server/tests/sole-definition.test.ts, no production change. Name entries (enforced, one home each): clearEndedSiblings, resolveSiblings, brokeredAppKind (store.ts), MIGRATIONS_JOURNAL (apply-migrations.ts), migratorHighWaterMark (migration-reach.ts). Spelling entries, path-keyed like the name table, comments stripped: - migrations-journal-literal: enforced, home apply-migrations.ts only. - sibling-cascade-walk: recorded debt A5b (retireConnectionsFor's own walk). - key-app-revoke-answer: recorded debt A5c (removeServer) plus the same divergence in retireConnectionsFor, found here. - migrator consumers (drizzle-kit / drizzle-orm migrator importers) must reference MIGRATIONS_JOURNAL: recorded debt C4n (drizzle.config.ts takes drizzle-kit's default). Every entry watched red on a planted second spelling and on a planted fix (stale debt). Co-Authored-By: Claude Opus 5.5 --- server/tests/sole-definition.test.ts | 328 ++++++++++++++++++++++++++- 1 file changed, 327 insertions(+), 1 deletion(-) diff --git a/server/tests/sole-definition.test.ts b/server/tests/sole-definition.test.ts index ed3648070..601b50326 100644 --- a/server/tests/sole-definition.test.ts +++ b/server/tests/sole-definition.test.ts @@ -42,7 +42,11 @@ import { expect, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { logicalLines, sourceFiles } from "./support/source-scan"; +import { + logicalLines, + sourceFiles, + stripComments, +} from "./support/source-scan"; const REPO = join(import.meta.dir, "../.."); @@ -91,6 +95,41 @@ const TRACKED: Tracked[] = [ names: ["keyedLookup"], home: "app/src/lib/lookup.ts", }, + /* + * Decisions this cycle made single-source, tracked by NAME from here on (lever L5). Each had + * exactly one definition when this landed. A name entry stops the decision being re-DEFINED under + * the same name; a re-spelling of its BODY under another name is what {@link SPELLED} below is for. + */ + /* The three sibling-cascade rules, extracted in round 2 (A5) and routed to by all callers (A5a). */ + { + decision: "sibling-cascade-rules", + names: ["clearEndedSiblings"], + home: "server/src/plugins/store.ts", + }, + /* Which apps a revoke ends besides the one it names: "the one place that answers it". */ + { + decision: "sibling-resolution", + names: ["resolveSiblings"], + home: "server/src/plugins/store.ts", + }, + /* Whether an app is a key, consent or neither, read off `mcp_servers`; every rule 1 asks it. */ + { + decision: "brokered-app-kind", + names: ["brokeredAppKind"], + home: "server/src/plugins/store.ts", + }, + /* The migrator's journal table, named once (C4). Its literal spellings are in SPELLED below. */ + { + decision: "migrations-journal", + names: ["MIGRATIONS_JOURNAL"], + home: "server/src/db/apply-migrations.ts", + }, + /* The migrator's high-water mark, "the single source for the mark" (C3). */ + { + decision: "migrator-high-water-mark", + names: ["migratorHighWaterMark"], + home: "server/src/db/migration-reach.ts", + }, ]; /** @@ -208,3 +247,290 @@ test("a definition is matched, and a call or a mention is not", () => { ]; for (const line of notDefinitions) expect(re.test(line)).toBe(false); }); + +/* + * ─── SPELLINGS: a decision re-written under no name at all (lever L5, class K5) ─────────────────── + * + * The name table above catches a decision DEFINED twice. It cannot catch the shape this cycle kept + * re-opening: a rule or a name made single-source in one place and then written out again, inline, + * somewhere that never binds a name to it — `retireConnectionsFor` re-spelling the sibling rules + * `clearEndedSiblings` owns (A5b), `removeServer` deciding a key app's revoke answer its own way + * where `disconnectBrokered` already had (A5c), a migrations-table name chosen by a tool's default + * where `MIGRATIONS_JOURNAL` had already chosen it (C4n). + * + * So each entry below is a decision and the TEXT that spells it: a pattern matched against every + * logical line of every source file, comments stripped first, so prose that DESCRIBES the rule (and + * this codebase describes its rules at length) is not a spelling of it. The comparison is the one + * the name table makes, in the same two directions and keyed the same way — by PATH, a file listed + * once per logical line that spells the decision — against either the home or the debt on record. + * + * WHAT A PATH KEY CANNOT SAY. Two spellings in one file are that path listed twice; which of the two + * is the home is not something a path can carry. For A5b and A5c the second spelling is in the SAME + * file as the first (`plugins/store.ts`), so the guard holds the COUNT: a third spelling anywhere, + * that file included, goes red; collapsing one goes red until the list drops a line. Moving the + * spelling from the owning function into a sibling function of the same file does not. That is the + * price of not going red on a rewrapped comment, and the same price the name table already pays. + */ + +/** + * The name table's roots, plus the two places a MIGRATION decision is made that a production + * runtime decision is not: the server's own scripts (`server/scripts/migrate.ts` is what a + * deployment runs) and `server/drizzle.config.ts` (what `drizzle-kit` reads). The name table's own + * {@link ROOTS} miss both; widening it is left to whoever owns that list. + */ +const SPELLING_ROOTS = [...ROOTS, "server/scripts", "server/drizzle.config.ts"]; + +/** Every source file under each root; a root that is a file is itself. */ +function spellingFiles(): string[] { + const out: string[] = []; + for (const root of SPELLING_ROOTS) { + const full = join(REPO, root); + if (!existsSync(full)) continue; + if (/\.tsx?$/.test(full)) out.push(full); + else out.push(...sourceFiles(full, [".ts", ".tsx"])); + } + return out.filter(isSource); +} + +/** + * A decision, the text that spells it, and — for one with no entry in + * {@link KNOWN_SECOND_SPELLINGS} — the paths of its home, once per logical line that spells it. + * + * The home is a LIST because a decision can rightly take more than one LOGICAL line in its own + * file. (The journal's schema and table are two physical lines but one logical line: a line ending + * in `,` is joined to the next by {@link logicalLines}, so the object counts once.) + */ +type Spelled = { decision: string; patterns: RegExp[]; home: string[] }; + +const SPELLED: Spelled[] = [ + /* + * The migrator's journal, as a literal. `MIGRATIONS_JOURNAL` is the one place a schema or table + * name is written; anything else either reads that object or is a second answer to the question + * the reach check and `migrate()` must answer identically (C4). + */ + { + decision: "migrations-journal-literal", + patterns: [ + /__drizzle_migrations/, + /\bmigrations(?:Schema|Table)\s*:\s*["'`]/, + // drizzle-kit's own spelling of the same two fields, in a `migrations: { … }` config block. + /\bmigrations\s*:\s*\{[^}]*\b(?:schema|table)\s*:\s*["'`]/, + ], + home: ["server/src/db/apply-migrations.ts"], + }, + /* + * THE SIBLING-CASCADE RULES: which of a revoke's siblings it ended. Spelled as a walk over a + * {@link resolveSiblings} answer, because every spelling of the three rules is one — rule 1 and + * rule 3 are each a `continue` inside that walk, rule 2 the guard before it. `clearEndedSiblings` + * is the one walk; a site that needs the rules calls it rather than walking the set itself. + */ + { + decision: "sibling-cascade-walk", + patterns: [ + /\bfor\s*\(\s*(?:const|let|var)\s+[\w$]+\s+of\s+[^;]*\bsiblings\b/, + // A MEMBER `.siblings` only: a bare `siblings` local is also how `shared-use-store.ts` names + // the rows of one app at one url, which is not a revoke's answer and not this decision. + /\.siblings\s*(?:\?\.|\.)\s*(?:forEach|map|flatMap|filter|reduce|some|every|find)\s*\(/, + ], + home: ["server/src/plugins/store.ts"], + }, + /* + * WHAT THE TRAIL SAYS WAS ASKED OF THE VENDOR, for the app an act was ABOUT. A key app's answer + * is `false` — nothing was asked of any vendor about a credential this deployment holds — and + * `disconnectBrokered` spells that once, as `askedIsKey ? false : ended`. A writer that computes + * the field from anything else is a second answer to the same question. A constant `true` or + * `false` is not a computation and is not matched: `clearEndedSiblings` writes `true` on rows + * rule 1 has already kept key apps out of, and the vault loops write `false` about secrets. + */ + { + decision: "key-app-revoke-answer", + patterns: [ + /\bvendorRevocationRequested\s*[:=]\s*(?!true\b|false\b|boolean\b)\S/, + ], + home: ["server/src/plugins/store.ts"], + }, +]; + +/** + * The second spellings standing when L5 landed, each with the open finding that owns collapsing it. + * + * THE SAME DISCIPLINE AS `typecheck-baseline.txt`: every site past the home is a real defect, not a + * thing that is fine. Collapsing one and deleting its line is always correct, and the guard goes red + * until the line is deleted. Adding a line is not: a new site is code written today re-spelling a + * decision somebody already made, and this exists to say so. + */ +const KNOWN_SECOND_SPELLINGS: Record< + string, + { finding: string; why: string; sites: string[] } +> = { + "sibling-cascade-walk": { + finding: "A5b (round 2, wave 3) — open", + why: "`retireConnectionsFor` builds its `endedWith` map by walking the siblings itself, with rule 2 (`if (!entry.requested) continue`) and without rule 1, so a key app's trail row can name an OAuth revoke as what ended it. Its shape is an annotation, not a delete, which is why it was not routed through `clearEndedSiblings` with the other three.", + sites: ["server/src/plugins/store.ts", "server/src/plugins/store.ts"], + }, + "key-app-revoke-answer": { + finding: + "A5c (round 2, wave 3) — open; the `retireConnectionsFor` line is the same divergence, found by L5", + why: "`removeServer` writes `vendorRevocationRequested[index] ?? false` — the vendor's raw answer — for the removed app even when it is a key app, and `retireConnectionsFor` writes `connection.requested || Boolean(endedBy)`, also raw for a key app; `disconnectBrokered` writes `false` for a key. One field, three answers.", + sites: [ + "server/src/plugins/store.ts", + "server/src/plugins/store.ts", + "server/src/plugins/store.ts", + ], + }, +}; + +/** Every source file in which one of `patterns` matches a logical line, comments stripped. */ +function spellingsOf(patterns: RegExp[]): string[] { + const out: string[] = []; + for (const file of spellingFiles()) { + const code = stripComments(readFileSync(file, "utf8")); + for (const { text } of logicalLines(code)) { + if (patterns.some((re) => re.test(text))) { + out.push(file.slice(REPO.length + 1)); + } + } + } + return out.sort(); +} + +for (const { decision, patterns, home } of SPELLED) { + test(`"${decision}" is spelled only at its home, or exactly the second spellings on record`, () => { + const found = spellingsOf(patterns); + const known = KNOWN_SECOND_SPELLINGS[decision]; + // Recorded debt: equality both ways, as for the name table. With none, the home and only it. + expect(found).toEqual([...(known ? known.sites : home)].sort()); + }); +} + +/* + * ─── IMPLICIT SPELLINGS: a consumer that names nothing, and so takes a tool's default ──────────── + * + * No pattern can match text that is not there. `drizzle.config.ts` names no migrations table, so + * `drizzle-kit migrate` uses drizzle-kit's OWN default — which happens today to be the same schema + * and table `MIGRATIONS_JOURNAL` names, and which nothing in this repository decides. That is a + * second spelling the literal scan above is blind to by construction (C4n), and the day either side + * changes, the reach check reads one table and the CLI writes another. + * + * So the condition is stated the other way round: every file that hands migrations to a migrator — + * imports `drizzle-kit`, or a `drizzle-orm//migrator` — must REFERENCE the constant in code. + * One that does not is taking somebody else's default. Consumers are discovered, not listed, so a + * new migrator entry point is held to this the day it lands. + */ +const JOURNAL_CONSUMER = + /\bfrom\s+["'](?:drizzle-kit|drizzle-orm\/[\w-]+\/migrator)["']/; +const JOURNAL_REFERENCE = /\bMIGRATIONS_JOURNAL\b/; + +/** Consumers that take a default instead of naming the journal, recorded as the debt they are. */ +const KNOWN_DEFAULT_TAKERS: { finding: string; sites: string[] } = { + finding: "C4n (round 2, wave 3) — open", + sites: ["server/drizzle.config.ts"], +}; + +/** Every migrator consumer whose CODE (comments stripped) never mentions the journal constant. */ +function defaultTakers(): string[] { + const out: string[] = []; + for (const file of spellingFiles()) { + const code = stripComments(readFileSync(file, "utf8")); + if (JOURNAL_CONSUMER.test(code) && !JOURNAL_REFERENCE.test(code)) { + out.push(file.slice(REPO.length + 1)); + } + } + return out.sort(); +} + +test("every migrator consumer names MIGRATIONS_JOURNAL, or is a default-taker on record", () => { + expect(defaultTakers()).toEqual([...KNOWN_DEFAULT_TAKERS.sites].sort()); +}); + +/** + * The spelling patterns' own positive and negative controls, for the reason the name pattern has + * one: a guard can be green because its pattern matches nothing at all. + */ +test("a spelling is matched, and a mention, a read or a type is not", () => { + const cases: Record = { + "migrations-journal-literal": { + spells: [ + ' migrationsTable: "__drizzle_migrations",', + ' migrationsSchema: "drizzle",', + 'migrations: { schema: "drizzle", table: "journal" },', + "select * from drizzle.__drizzle_migrations", + ], + doesNot: [ + " migrationsSchema: string;", + " migrations: { schema: MIGRATIONS_JOURNAL.migrationsSchema },", + "from client(MIGRATIONS_JOURNAL.migrationsTable)", + ], + }, + "sibling-cascade-walk": { + spells: [ + "for (const app of new Set(input.siblings)) {", + "for (const app of endedBesides.get(entry.toolkit)?.siblings ?? []) {", + "for (let app of siblings) {", + "resolved.siblings.forEach((app) => {", + "input.siblings.filter((app) => app !== toolkit)", + ], + doesNot: [ + "await clearEndedSiblings({ siblings, account, endedWith: toolkit });", + "siblings: input.siblings,", + "const { siblings, unanswered } = await resolveSiblings(x);", + "for (const connection of brokered) {", + "const ids = siblings.map((sibling) => sibling.id);", + ], + }, + "key-app-revoke-answer": { + spells: [ + "const vendorRevocationRequested = askedIsKey ? false : ended;", + "vendorRevocationRequested: vendorRevocationRequested[index] ?? false,", + "vendorRevocationRequested: connection.requested || Boolean(endedBy),", + "vendorRevocationRequested: ended,", + ], + doesNot: [ + "vendorRevocationRequested: true,", + "vendorRevocationRequested: false,", + "}): Promise<{ vendorRevocationRequested: boolean }> {", + "const vendorRevocationRequested: boolean[] = [];", + "vendorRevocationRequested?: boolean;", + "return { vendorRevocationRequested };", + ], + }, + }; + for (const { decision, patterns } of SPELLED) { + const { spells, doesNot } = cases[decision] ?? { spells: [], doesNot: [] }; + // Every entry has controls: an entry added without them fails here rather than going unproven. + expect(spells.length).toBeGreaterThan(0); + for (const line of spells) { + expect({ + decision, + line, + hit: patterns.some((re) => re.test(line)), + }).toEqual({ + decision, + line, + hit: true, + }); + } + for (const line of doesNot) { + expect({ + decision, + line, + hit: patterns.some((re) => re.test(line)), + }).toEqual({ + decision, + line, + hit: false, + }); + } + } + expect( + JOURNAL_CONSUMER.test('import { defineConfig } from "drizzle-kit";'), + ).toBe(true); + expect( + JOURNAL_CONSUMER.test( + 'import { migrate } from "drizzle-orm/postgres-js/migrator";', + ), + ).toBe(true); + expect( + JOURNAL_CONSUMER.test('import { drizzle } from "drizzle-orm/postgres-js";'), + ).toBe(false); +}); From 0c4859f2bdbe9e1ba5cd99ee1455abb9cd92b5d4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:09:14 +0200 Subject: [PATCH 212/232] test(L3): ratchet casts past the broker type; add a throw-by-default stubBroker Convergence lever L3 (class K3): test doubles that get past typecheck:tests. - broker-stub-ratchet.test.ts scans server/tests for (a) casts to a broker or credential seam type (as X, as unknown as X, {} as X, as Partial), (b) a broker erased to never/any/unknown, and (c) silent no-op revokeAccount stubs. Each list is keyed by path + quoted expression and compared with equality both ways: a new site fails, a fixed site must be deleted. Recorded today: 8 casts, 4 erasures, 22 no-op revokes. Matcher contract tests pin what each pattern does and does not match. - support/stub-broker.ts: stubBroker({ grantIsLocal, authorizations, ...methods }) builds a ConnectedAppBroker with no cast. The two declarations are required; every method not provided records the reach and throws synchronously; expectNothingUnprovidedReached() catches a reach that production swallowed. Calls to provided members are recorded. - stub-broker.test.ts: contract tests and @ts-expect-error type pins. - Worked example: store-scheme-sites.test.ts (already in this PR's diff, already honest after C8a) now uses stubBroker. Its one credential baseline entry moves 102 -> 88; no entry added or removed. No production file changed. None of the recorded cast or no-op sites was migrated. Co-Authored-By: Claude Opus 5.5 --- server/tests/broker-stub-ratchet.test.ts | 262 +++++++++++++++++++++++ server/tests/store-scheme-sites.test.ts | 56 ++--- server/tests/stub-broker.test.ts | 117 ++++++++++ server/tests/support/stub-broker.ts | 117 ++++++++++ server/tests/typecheck-baseline.txt | 2 +- 5 files changed, 518 insertions(+), 36 deletions(-) create mode 100644 server/tests/broker-stub-ratchet.test.ts create mode 100644 server/tests/stub-broker.test.ts create mode 100644 server/tests/support/stub-broker.ts diff --git a/server/tests/broker-stub-ratchet.test.ts b/server/tests/broker-stub-ratchet.test.ts new file mode 100644 index 000000000..3814450ff --- /dev/null +++ b/server/tests/broker-stub-ratchet.test.ts @@ -0,0 +1,262 @@ +// server/tests/broker-stub-ratchet.test.ts +/** + * Ratchet: no NEW test double gets past the broker or credential type by a cast, and no NEW + * `revokeAccount` stub silently succeeds. + * + * WHY THIS EXISTS. `typecheck:tests` (CL-4) type-checks `server/tests` and fails on any new error, + * but a cast is not an error. `{ … } as unknown as ConnectedAppBroker` and `{} as + * ConnectedAppBroker` compile while omitting `revoke` and `revokeAccount`; `{ broker } as never` + * compiles while omitting everything. Each one makes the type's guarantee vanish exactly as the + * broken `import type` that round-2 C8 fixed did — the same defect by a different mechanism, and + * one the compiler gate is structurally blind to. A `revokeAccount: async () => {}` stub is the + * runtime half of the same drift: it type-checks, and answers "done" for a revoke that never + * happened, on the seam this PR's Claim 2 is about. + * + * THE LISTS BELOW ARE A RECORD OF DEBT, in the discipline of `typecheck-baseline.txt`: every entry + * is a real stub the compiler cannot see, not a stub that is fine. Removing one by fixing it is + * always correct — and REQUIRED, because the comparison is equality both ways, so a fixed site + * left on the list goes red and names itself for deletion. Adding one is not: a new cast means a + * double written today escapes the type, and the honest alternative exists + * (`support/stub-broker.ts`, whose unprovided members throw and record). The migration of the + * recorded sites onto that factory is deferred to the fix cycle (convergence audit, K3); this + * file only reads them. + * + * KEYED BY PATH + QUOTED EXPRESSION, NEVER `path:line`. A line-keyed list goes red when somebody + * adds an import above a stub, and a guard that fails for a reason unrelated to what it guards is + * one people learn to re-baseline without reading (`assertion-shape.test.ts` collided this way in + * three consecutive waves; `sole-definition.test.ts`, keyed like this, never has). Two identical + * expressions in one file appear as that entry twice — duplicates are significant. + * + * WHAT THE PATTERNS SEE, AND WHAT THEY DO NOT. Comments are blanked by the shared scanner, so a + * docblock quoting a cast is prose. Matching runs over the whole stripped file with `\s+`, so a + * cast wrapped across lines is still one cast. NOT seen: a cast to a local alias of the broker type + * whose name does not end in `Broker` (`type B = ConnectedAppBroker; x as unknown as B`), a stub + * erased by `as never` whose line names no broker (`deps as never` where `deps` holds one), and a + * no-op `revokeAccount` written through a named function (`revokeAccount: noop`). Widening to those + * would flag every `as never` in the suite; the escape hatch is review, and the factory. + */ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { sourceFiles, stripComments } from "./support/source-scan"; + +const REPO = join(import.meta.dir, "../.."); + +/** + * This file is not scanned: its lists QUOTE every recorded site, and its matcher contract spells + * the offences it must catch, so scanning it would report its own records as new stubs. + */ +const SELF = import.meta.path; +const ROOT = "server/tests"; + +/** The seam types a double must not be cast to. A name ending in `Broker` covers every alias. */ +const SEAM_TYPE = + "(?:[A-Z][\\w$]*)?(?:Broker|CredentialStore|CredentialSecretReader|CredentialStatusReader|CredentialAdminService)"; + +/** + * A type assertion TO a seam type: `as X`, `as unknown as X`, `as any as X`, `as never as X`, and + * the wrapped forms `as Partial` / `as unknown as Pick`. `{} as X` is `as X`. + */ +export const CAST_TO_SEAM = new RegExp( + `\\bas\\s+(?:(?:unknown|any|never)\\s+as\\s+)?(?:[\\w$]+\\s*<\\s*)?${SEAM_TYPE}(?![\\w$])`, + "g", +); + +/** + * A broker ERASED rather than cast: an expression that names a broker — the `broker` key or + * binding, or any identifier ending in `Broker` — asserted to `never`, `any` or `unknown`, within + * the same statement. The span stops at a `;` or a line break, so an unrelated `as never` further + * down the file is not attributed to it. + */ +export const ERASED_BROKER = + /\b(?:broker|[a-z][\w$]*Broker)\b[^;\n]*?\bas\s+(?:never|any|unknown)\b(?!\s+as\b)/g; + +/** A `revokeAccount` that succeeds and does nothing: the silent no-op. */ +export const NOOP_REVOKE_ACCOUNT = + /\brevokeAccount\s*(?::\s*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*(?::\s*[^=]+?)?=>\s*(?:\{\s*\}|undefined\b|void\s+0\b|Promise\.resolve\(\s*\))|(?:async\s+)?\([^)]*\)\s*(?::\s*[^{]+?)?\{\s*\})/g; + +/** + * Every match of `re` across the test tree, as `path :: expression`. + * + * The expression is the whole physical line(s) the match spans, whitespace collapsed, so it reads + * as the stub a reviewer would see and survives reindentation. + */ +export function sitesOf(re: RegExp, root = ROOT): string[] { + const out: string[] = []; + for (const file of sourceFiles(join(REPO, root), [".ts", ".tsx"])) { + if (file === SELF) continue; + const text = stripComments(readFileSync(file, "utf8")); + for (const match of text.matchAll(re)) { + out.push( + `${file.slice(REPO.length + 1)} :: ${expressionAt(text, match)}`, + ); + } + } + return out.sort(); +} + +function expressionAt(text: string, match: RegExpMatchArray): string { + const at = match.index ?? 0; + const from = text.lastIndexOf("\n", at) + 1; + const end = text.indexOf("\n", at + match[0].length); + return text + .slice(from, end === -1 ? text.length : end) + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Casts past the broker type on record. Round-2 C8a's report counted six; the scan finds EIGHT — + * `store-account-ref.integration` and `store-vendor-vocabulary` were not in that count. Each one + * compiles whatever it omits, so none is checked against the seam; all are deferred to the fix + * cycle (K3). + */ +const KNOWN_CASTS: string[] = [ + "server/tests/directory-vendor-sentences.test.ts :: } as unknown as ComposioBroker;", + "server/tests/routes-arcade-urls.test.ts :: } as unknown as ComposioBroker;", + "server/tests/routes-vendor-vocabulary.test.ts :: } as unknown as ComposioBroker;", + "server/tests/store-account-ref.integration.test.ts :: } as unknown as ConnectedAppBroker;", + "server/tests/store-provider-required.test.ts :: const broker = {} as ConnectedAppBroker;", + "server/tests/store-toolkit-dispatch.test.ts :: const broker = {} as ConnectedAppBroker;", + "server/tests/store-vendor-vocabulary.test.ts :: } as unknown as ConnectedAppBroker;", + "server/tests/vendor-deadline-sync-throw.test.ts :: } as unknown as ComposioBroker;", +]; + +/** Brokers erased to `never` on record. Same defect, wider: nothing of the seam is checked. */ +const KNOWN_ERASED: string[] = [ + 'server/tests/arcade-journey.integration.test.ts :: args[29] = { broker, provider: "arcade" } as never;', + "server/tests/plugin-routes.test.ts :: listApps ? ({ broker: { listApps } } as never) : undefined,", + 'server/tests/provider-selection.test.ts :: const arcadeBroker = { tag: "arcade-broker" } as never;', + 'server/tests/provider-selection.test.ts :: const composioBroker = { tag: "composio-broker" } as never;', +]; + +/** + * Silent no-op `revokeAccount` stubs on record. Round-2 C8c counted 21; the scan finds 22. Deferred + * to the fix cycle (K1/K3). + */ +const KNOWN_NOOP_REVOKES: string[] = [ + "server/tests/account-mode.integration.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-attempt-store.integration.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-authorize-cancel.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-authorize-route.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-completion-transaction.integration.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-disconnect-cascade.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-disconnect-route.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-grants-route.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-grants-route.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-sibling-collateral.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-siblings.test.ts :: revokeAccount: async () => {},", + "server/tests/arcade-verify.test.ts :: revokeAccount: async () => {},", + "server/tests/composio-grants-for.test.ts :: revokeAccount: async () => {},", + "server/tests/gate-foreign-provenance-call.test.ts :: revokeAccount: async () => {},", + "server/tests/gate-grant-is-local.test.ts :: revokeAccount: async () => {},", + "server/tests/remove-server-foreign-provenance.test.ts :: revokeAccount: async () => {},", + "server/tests/shared-brokered-routes.test.ts :: revokeAccount: async () => {},", + "server/tests/shared-brokered-store.integration.test.ts :: revokeAccount: async () => {},", + "server/tests/shared-gated-mode.integration.test.ts :: revokeAccount: async () => {},", + "server/tests/shared-wiring.integration.test.ts :: revokeAccount: async () => {},", + "server/tests/store-account-ref.integration.test.ts :: revokeAccount: async () => {},", + "server/tests/store-vendor-vocabulary.test.ts :: revokeAccount: async () => {},", +]; + +test("no new cast past the broker or credential type", () => { + // Equality both ways: a NEW site is a double written today that escapes the type; a site that + // is GONE was fixed, and its entry must be deleted above so the record burns down. + expect(sitesOf(CAST_TO_SEAM)).toEqual([...KNOWN_CASTS].sort()); +}); + +test("no new broker erased to never, any or unknown", () => { + const erased = sitesOf(ERASED_BROKER).filter( + (site) => !new RegExp(CAST_TO_SEAM.source).test(site), + ); + expect(erased).toEqual([...KNOWN_ERASED].sort()); +}); + +test("no new silent no-op revokeAccount stub", () => { + expect(sitesOf(NOOP_REVOKE_ACCOUNT)).toEqual([...KNOWN_NOOP_REVOKES].sort()); +}); + +/* + * THE MATCHER CONTRACT. The real-tree comparisons above prove the lists are current; these prove + * the patterns are read the way the lists assume — that a cast is matched when it is one and a + * typed binding or a `satisfies` is not. Without them the ratchet could be green because a pattern + * matches nothing at all. + */ +const B = "ConnectedAppBroker"; +const C = "CredentialStore"; +const RA = "revokeAccount"; +const BK = "broker"; + +function matches(re: RegExp, text: string): boolean { + return new RegExp(re.source).test(text); +} + +test("a cast to a seam type is matched; a typed binding, a satisfies or a near-name is not", () => { + const casts = [ + `} as unknown as ${B};`, + `const broker = {} as ${B};`, + `} as ${B};`, + `x as any as ${B}`, + `x as never as Composio${"Broker"}`, + `x as Partial<${B}>`, + `x as unknown as Pick<${B}, "revoke">`, + `} as\n unknown as\n ${B};`, + `credentials: {} as ${C},`, + `x as unknown as HostAccess${"Broker"}`, + ]; + for (const text of casts) + expect([text, matches(CAST_TO_SEAM, text)]).toEqual([text, true]); + const notCasts = [ + `const broker: ${B} = {`, + `} satisfies ${B};`, + `import type { ${B} } from "../src/plugins/broker";`, + `x as ${"Broker"}edProbe`, + `x as ${"Broker"}App`, + `alias as const`, + `has ${B}`, + ]; + for (const text of notCasts) + expect([text, matches(CAST_TO_SEAM, text)]).toEqual([text, false]); +}); + +test("an erased broker is matched; an erased unrelated value is not", () => { + const erased = [ + `args[29] = { ${BK}, provider: "arcade" } as never;`, + `listApps ? ({ ${BK}: { listApps } } as never) : undefined,`, + `const composio${"Broker"} = { tag: "x" } as never;`, + `const deps = { ${BK}: stub } as any;`, + ]; + for (const text of erased) + expect([text, matches(ERASED_BROKER, text)]).toEqual([text, true]); + const notErased = [ + `(brokerReturnUrl as any)(" "),`, + `new (${"Broker"}UnconfiguredError as any)()`, + `credentials: {} as never,`, + `${BK},\n credentials: {} as never,`, + `const ${BK} = make(); const y = z as never;`, + ]; + for (const text of notErased) + expect([text, matches(ERASED_BROKER, text)]).toEqual([text, false]); +}); + +test("a silent revokeAccount is matched; one that records or throws is not", () => { + const silent = [ + ` ${RA}: async () => {},`, + ` ${RA}: async (_id) => {},`, + ` ${RA}: async (id: string) => {\n },`, + ` ${RA}: async () => undefined,`, + ` ${RA}: () => Promise.resolve(),`, + ` async ${RA}() {},`, + ` ${RA}(_id: string) {}`, + ]; + for (const text of silent) + expect([text, matches(NOOP_REVOKE_ACCOUNT, text)]).toEqual([text, true]); + const honest = [ + ` ${RA}: async (id) => {\n asks.push(id);\n },`, + ` ${RA}: unasked("revoke one account"),`, + ` ${RA}: async () => {\n throw new Error("no");\n },`, + ` await ${BK}.${RA}("ca_1");`, + ]; + for (const text of honest) + expect([text, matches(NOOP_REVOKE_ACCOUNT, text)]).toEqual([text, false]); +}); diff --git a/server/tests/store-scheme-sites.test.ts b/server/tests/store-scheme-sites.test.ts index 6af248950..e2011904d 100644 --- a/server/tests/store-scheme-sites.test.ts +++ b/server/tests/store-scheme-sites.test.ts @@ -21,13 +21,13 @@ import { } from "../src/db/schema"; import { BROKERED_PROFILE, type BrokerProvider } from "../src/plugins/access"; import type { BrokerConnection } from "../src/plugins/broker"; -import type { ComposioBroker } from "../src/plugins/broker"; import { CustomServerRefusedError, createPluginStore, } from "../src/plugins/store"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; import { stripComments } from "./support/source-scan"; +import { stubBroker } from "./support/stub-broker"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); @@ -55,46 +55,32 @@ afterAll(async () => { }); /** - * Every broker member this file does not expect production to reach, by the name it was asked as. + * The broker, built by `stubBroker`: every member this file does not provide THROWS AND RECORDS. * - * EACH ONE THROWS AND RECORDS, because a throw is only as loud as the path that receives it: a - * `catch` around the call swallows it and the test passes on a broker that was never there. That is - * how three members of `ConnectedAppBroker` — `revokeAccount` among them — were absent from this - * stub without a single test noticing. The check below reads the record after every test, so a - * reach is a red whether or not production let the throw out. + * A throw is only as loud as the path that receives it: a `catch` around the call swallows it and + * the test passes on a broker that was never there. That is how three members of + * `ConnectedAppBroker` — `revokeAccount` among them — were absent from this stub without a single + * test noticing (round-2 C8a). The check below reads the record after every test, so a reach is a + * red whether or not production let the throw out. Only what these paths actually reach is + * provided; nothing here reads grants, lists, deletes, authorizes, checks, revokes, draws a connect + * form, connects with typed fields or undoes one account. */ -const reached: string[] = []; -const unasked = (what: string) => async (): Promise => { - reached.push(what); - throw new Error(`asked the broker to ${what}`); -}; +const stub = stubBroker({ + grantIsLocal: true, + // Nothing here reads it; declared because the seam requires a broker to SAY what it runs — see + // `ConnectedAppBroker.authorizations`. + authorizations: null, + // Nothing here asks what auth provider a connection is held under. + authProviderOf: async () => null, + ensureAuthConfig: async () => "created", + accountName: async () => null, +}); afterEach(() => { - expect(reached.splice(0)).toEqual([]); + stub.expectNothingUnprovidedReached(); }); function storeFor(provider: BrokerProvider) { - const broker: ComposioBroker = { - grantIsLocal: true, - // Nothing here asks what auth provider a connection is held under. - authProviderOf: async () => null, - // Nothing here reads either member; both are declared because the seam requires a broker to - // SAY what it runs — see `ConnectedAppBroker.authorizations`. - authorizations: null, - grantsFor: unasked("read grants"), - listApps: unasked("list"), - ensureAuthConfig: async () => "created", - deleteAuthConfig: unasked("delete"), - authorize: unasked("authorize"), - isConnected: unasked("check"), - revoke: unasked("revoke"), - // Nothing here draws a connect form, connects with typed fields or undoes one account. All three - // are declared so the stub is a `ConnectedAppBroker` rather than a partial one, and all three - // fail if reached. - connectionFields: unasked("read connection fields"), - connectWithFields: unasked("connect with typed fields"), - revokeAccount: unasked("revoke one account"), - accountName: async () => null, - }; + const broker = stub.broker; return createPluginStore({ brokerProvider: provider, database, diff --git a/server/tests/stub-broker.test.ts b/server/tests/stub-broker.test.ts new file mode 100644 index 000000000..adc95d5e8 --- /dev/null +++ b/server/tests/stub-broker.test.ts @@ -0,0 +1,117 @@ +// server/tests/stub-broker.test.ts +/** + * Contract test for `support/stub-broker.ts`: an unprovided member fails loudly, a swallowed reach + * is still caught, a provided member is used and recorded, and the declarations cannot be skipped. + */ +import { expect, test } from "bun:test"; +import type { ConnectedAppBroker } from "../src/plugins/broker"; +import { + type BrokerMethod, + stubBroker, + unprovidedMessage, +} from "./support/stub-broker"; + +/** Every method of the seam, spelled out so a member the factory forgot is a test failure too. */ +const METHODS: BrokerMethod[] = [ + "grantsFor", + "authProviderOf", + "listApps", + "ensureAuthConfig", + "deleteAuthConfig", + "authorize", + "isConnected", + "revoke", + "connectionFields", + "connectWithFields", + "revokeAccount", + "accountName", +]; + +const account = { + holder: "person", + userId: "u_1", + vendorUserId: "v_1", +} as const; + +test("every method the test did not provide throws, and records that it was reached", () => { + const stub = stubBroker({ grantIsLocal: true, authorizations: null }); + for (const name of METHODS) { + const member = stub.broker[name] as unknown as () => unknown; + expect(() => member()).toThrow(unprovidedMessage(name)); + } + expect(stub.unprovided).toEqual(METHODS); + expect(stub.calls).toEqual([]); +}); + +test("the stub has exactly the seam's members, no more and no fewer", () => { + const stub = stubBroker({ grantIsLocal: false, authorizations: null }); + expect(Object.keys(stub.broker).sort()).toEqual( + ["grantIsLocal", "authorizations", ...METHODS].sort(), + ); +}); + +test("a reach that production SWALLOWS is still caught by the after-each check", async () => { + const stub = stubBroker({ grantIsLocal: true, authorizations: null }); + // The shape round-2 C8a planted in `store.ts`: a revoke whose failure is caught and dropped. + // A throw alone passes here; the record is what makes it a red. + try { + await stub.broker.revokeAccount("ca_1"); + } catch {} + expect(() => stub.expectNothingUnprovidedReached()).toThrow(); + // The check clears the record, so one reach is reported once. + expect(() => stub.expectNothingUnprovidedReached()).not.toThrow(); +}); + +test("a fire-and-forget .catch cannot swallow it: the throw is synchronous", () => { + const stub = stubBroker({ grantIsLocal: true, authorizations: null }); + expect(() => stub.broker.revokeAccount("ca_1").catch(() => {})).toThrow( + unprovidedMessage("revokeAccount"), + ); +}); + +test("a provided member is the one called, and every call to it is recorded", async () => { + const revoked: string[] = []; + const stub = stubBroker({ + grantIsLocal: true, + authorizations: null, + revokeAccount: async (id) => { + revoked.push(id); + }, + accountName: async () => "Ada", + }); + await stub.broker.revokeAccount("ca_1"); + expect(await stub.broker.accountName({ account, toolkit: "gmail" })).toBe( + "Ada", + ); + expect(revoked).toEqual(["ca_1"]); + expect(stub.calls).toEqual([ + { member: "revokeAccount", args: ["ca_1"] }, + { member: "accountName", args: [{ account, toolkit: "gmail" }] }, + ]); + stub.expectNothingUnprovidedReached(); +}); + +test("the declarations are passed through as given, never defaulted", () => { + const stub = stubBroker({ grantIsLocal: false, authorizations: null }); + expect(stub.broker.grantIsLocal).toBe(false); + expect(stub.broker.authorizations).toBeNull(); +}); + +/* + * THE TYPE PINS. These fail `typecheck:tests` if the factory ever starts defaulting a declaration or + * accepting a member of the wrong shape — and fail it the other way, on an unused directive, if the + * error they expect goes away. + */ +// @ts-expect-error - grantIsLocal is a declaration the test must make. +const _withoutGrantIsLocal = () => stubBroker({ authorizations: null }); +// @ts-expect-error - authorizations is a declaration the test must make, even when it is null. +const _withoutAuthorizations = () => stubBroker({ grantIsLocal: true }); +const _wrongShape = () => + stubBroker({ + grantIsLocal: true, + authorizations: null, + // @ts-expect-error - revokeAccount answers Promise, not a boolean. + revokeAccount: () => true, + }); +const _typedAsSeam: () => ConnectedAppBroker = () => + stubBroker({ grantIsLocal: true, authorizations: null }).broker; diff --git a/server/tests/support/stub-broker.ts b/server/tests/support/stub-broker.ts new file mode 100644 index 000000000..16aadc51e --- /dev/null +++ b/server/tests/support/stub-broker.ts @@ -0,0 +1,117 @@ +// server/tests/support/stub-broker.ts +/** + * A `ConnectedAppBroker` test double whose every member the test did not provide FAILS LOUDLY. + * + * WHY THIS EXISTS. Broker stubs drifted from the seam in two ways, and the type checker saw + * neither: + * + * - CAST PAST THE TYPE. `{ … } as unknown as ConnectedAppBroker` or `{} as ConnectedAppBroker` + * makes a stub that omits `revoke` and `revokeAccount` compile. The compiler's guarantee is + * gone exactly as it was when a broken `import type` degraded the type to `any` (round-2 C8). + * `broker-stub-ratchet.test.ts` stops new casts; this factory is what to write instead. + * - A PLAUSIBLE DEFAULT. `revokeAccount: async () => {}` type-checks and answers "done" for a + * revoke that never happened. A test can then pass on a fiction, and the revoke seam is the + * one this PR's Claim 2 is about. + * + * So the factory takes the members a test actually means, and everything else becomes a member + * that RECORDS that it was reached and THROWS. This hoists round-2 C8a's `unasked` pattern into one + * place: a throw alone is only as loud as the path that receives it — a `catch` in production + * swallows it and the test passes — which is why each reach is also recorded, and + * {@link BrokerStub.expectNothingUnprovidedReached} (run it in an `afterEach`) reads the record. + * + * WHAT THE SIGNATURE FORCES. + * + * - `grantIsLocal` and `authorizations` are REQUIRED. They are declarations, not behaviour; the + * seam makes a broker SAY them (see `ConnectedAppBroker.authorizations`), and a factory that + * defaulted them would reintroduce the plausible-default defect one field along. + * - Every method is optional, and an omitted one throws. There is no default that returns a + * value, on purpose: a test that needs `revokeAccount` to succeed writes that, in its own file, + * where a reviewer reads it. + * - The result is built as a `ConnectedAppBroker` OBJECT LITERAL with no cast, so adding a member + * to the seam fails `typecheck:tests` here until the factory learns it, instead of every stub + * in the suite silently lacking it. + * + * Every call to a PROVIDED member is recorded too, in {@link BrokerStub.calls}, so an honest stub + * that wants to assert "revokeAccount was asked for ca_1" does not have to hand-roll a recorder. + */ +import { expect } from "bun:test"; +import type { ConnectedAppBroker } from "../../src/plugins/broker"; + +/** The members a broker DECLARES rather than does. Required: there is no honest default. */ +type Declared = "grantIsLocal" | "authorizations"; + +/** What a test hands {@link stubBroker}: the declarations, plus whichever methods it means. */ +export type StubBrokerInput = Pick & + Partial>; + +/** The name of a broker method, i.e. every member that is not a declaration. */ +export type BrokerMethod = Exclude; + +export type BrokerStub = { + /** The double itself, typed as the seam with no cast. */ + broker: ConnectedAppBroker; + /** Every call to a member the test PROVIDED, in order. */ + calls: Array<{ member: BrokerMethod; args: unknown[] }>; + /** Every call to a member the test did NOT provide, in order — each of which also threw. */ + unprovided: BrokerMethod[]; + /** Asserts no unprovided member was reached since the last check, and clears the record. */ + expectNothingUnprovidedReached(): void; +}; + +/** The sentence an unprovided member throws, exported so a test can match it exactly. */ +export function unprovidedMessage(member: BrokerMethod): string { + return `stubBroker: production reached broker.${member}, which this test did not provide`; +} + +export function stubBroker(input: StubBrokerInput): BrokerStub { + const calls: BrokerStub["calls"] = []; + const unprovided: BrokerMethod[] = []; + + /** + * The provided member, recorded; or, when absent, a member that records and throws. + * + * The throw is SYNCHRONOUS. Every broker method returns a promise, so an `await` sees it the + * same as a rejection, while a fire-and-forget `.catch(() => {})` cannot swallow it. + */ + function member unknown>( + name: BrokerMethod, + provided: F | undefined, + ): ((...args: Parameters) => ReturnType) | (() => never) { + if (provided === undefined) { + return () => { + unprovided.push(name); + throw new Error(unprovidedMessage(name)); + }; + } + return (...args: Parameters) => { + calls.push({ member: name, args }); + return provided(...args) as ReturnType; + }; + } + + const broker: ConnectedAppBroker = { + grantIsLocal: input.grantIsLocal, + authorizations: input.authorizations, + grantsFor: member("grantsFor", input.grantsFor), + authProviderOf: member("authProviderOf", input.authProviderOf), + listApps: member("listApps", input.listApps), + ensureAuthConfig: member("ensureAuthConfig", input.ensureAuthConfig), + deleteAuthConfig: member("deleteAuthConfig", input.deleteAuthConfig), + authorize: member("authorize", input.authorize), + isConnected: member("isConnected", input.isConnected), + revoke: member("revoke", input.revoke), + connectionFields: member("connectionFields", input.connectionFields), + connectWithFields: member("connectWithFields", input.connectWithFields), + revokeAccount: member("revokeAccount", input.revokeAccount), + accountName: member("accountName", input.accountName), + }; + + return { + broker, + calls, + unprovided, + expectNothingUnprovidedReached() { + expect(unprovided.splice(0)).toEqual([]); + }, + }; +} diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index 792e9a544..1e38876ec 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -255,7 +255,7 @@ server/tests/settings-instructions-routes.test.ts:314: error TS7006: Parameter ' server/tests/skill-ownership.integration.test.ts:29: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/skill-tools.integration.test.ts:35: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/skill-uninstall-grants.integration.test.ts:26: error TS2322: Type '{ readSecret: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/store-scheme-sites.test.ts:102: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/store-scheme-sites.test.ts:88: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: () => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/tenant-package.test.ts:1440: error TS2698: Spread types may only be created from object types. server/tests/tenant-package.test.ts:1518: error TS2698: Spread types may only be created from object types. server/tests/thread-routes.test.ts:22: error TS2769: No overload matches this call. From dab76a5af49f612323cca556f3a4fd22b1fb388a Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:10:03 +0200 Subject: [PATCH 213/232] test(L4): ledger every pending_authorizations write against its audit decision Convergence lever L4 (class K4). Strictly additive: one new test file, no production change, no audit event added. server/tests/pending-authorization-audit-ledger.test.ts scans server/src, server/scripts, scripts, worker and shared (through support/source-scan) for every insert/update/delete/truncate on the table, by drizzle symbol, by the raw table name and by an interpolated symbol. It also reads server/drizzle/*.sql for DML and ON DELETE cascades. Each write must have a ledger row: audited (event + filer, checked), debt (finding id), or waived (an argued reason). Statements are keyed by enclosing function + expression, call sites by file + method + ordinal + anchor, and cascades by constraint name. None of them is keyed by path:line. Initial ledger: 2 audited (cancel, completed), 13 debt (9 transitions B5e, 3 cascades B5e, 1 hand-listed non-write B2c), 1 waived (0060 orphan purge). DEBT_CEILING pins the debt count at 13. Boundary: it checks the declaration, not the firing, and it cannot discover a transition that writes nothing (B2c is listed by hand and checked only for still existing). Call sites enumerated: no signature or behaviour changed. Co-Authored-By: Claude Opus 5.5 --- ...pending-authorization-audit-ledger.test.ts | 774 ++++++++++++++++++ 1 file changed, 774 insertions(+) create mode 100644 server/tests/pending-authorization-audit-ledger.test.ts diff --git a/server/tests/pending-authorization-audit-ledger.test.ts b/server/tests/pending-authorization-audit-ledger.test.ts new file mode 100644 index 000000000..2b77e47c3 --- /dev/null +++ b/server/tests/pending-authorization-audit-ledger.test.ts @@ -0,0 +1,774 @@ +// server/tests/pending-authorization-audit-ledger.test.ts +/** + * Guard: every write to `pending_authorizations` has a RECORDED AUDIT DECISION. It names the event + * it files, or it is recorded as debt, or it carries a waiver that argues why no event fits. + * + * WHY THIS EXISTS. This PR's subject is attributable consent: a mint/claim/complete lifecycle whose + * return leg is bound to the attempt the signed-in person actually minted. Round 2 wave 3 (B5e) + * counted the transitions that lifecycle can make and found that one in nine filed an audit row. + * Nothing had ever forced the question "which event?" when a write was added, so the question was + * never asked. This ledger asks it. A new write site with no entry below fails, and so does an + * entry whose site is gone. Adding an unaudited transition is now something somebody does on + * purpose and writes down. + * + * THREE DISPOSITIONS, AND TWO OF THEM MUST NOT BE CONFUSED. + * + * - `audited`: names an event in `auditEventTypes` and the function that files it. The guard + * checks that the named function contains that event literal. When the filer is not the writer + * itself, it also checks that a call to the filer sits next to the write. + * - `debt`: this transition SHOULD be audited and is not. That is a finding, and it carries the + * finding id. It follows the `typecheck-baseline.txt` discipline: converting a debt row into + * `audited` and deleting the debt is always correct. Adding one is not. {@link DEBT_CEILING} + * pins the count so a new debt row has to be argued for in the same diff that adds it. + * - `waived`: NO audit event is appropriate here. That is a judgement, and the reason is written + * down. "It is not audited today" is never a waiver. That is debt. + * + * HOW IT IS KEYED, AND WHY NOT `path:line`. `assertion-shape.test.ts` is keyed by line and collided + * in three consecutive waves. Here: + * + * - A write STATEMENT is keyed by its enclosing function's name plus the quoted write + * expression. A migration is keyed by its file name instead. + * - A CALL SITE of a writing method is keyed by file + method + ordinal (source order). An anchor + * string confirms that each ordinal is still the transition the ledger says it is. The nearest + * same-method call to the anchor must be this one. + * - A CASCADE is keyed by its constraint name. + * + * Moving code around, rewrapping a comment or adding a line above changes none of these keys. + * + * WHAT GREEN MEANS, AND WHAT IT DOES NOT. Read this before trusting it. + * + * 1. IT CHECKS THE DECLARATION, NOT THE FIRING. For `audited` rows it checks that the event exists, + * that the named filer files it, and that the filer is called beside the write. It does not + * prove the event fires on every path through that write, or that the payload is clean (no + * bearer consent url, no live `auth_id`). The behavioural plants for that are in + * `arcade-authorize-cancel.test.ts` (B5b) and the completion tests. + * 2. IT CANNOT SEE A TRANSITION THAT WRITES NOTHING. B2c is the example: a cancel that LOST the + * race to a completion deletes no row, so no scan of write sites can find it. Such non-writes + * are listed BY HAND in {@link NON_WRITES}. The guard only checks that each listed one still + * exists where it says, so the list cannot go stale silently. It DISCOVERS NOTHING. A new + * silent branch that writes nothing stays invisible here. + * 3. IT SEES WRITES SPELLED THROUGH THE DRIZZLE SYMBOL OR THE LITERAL TABLE NAME. A table name + * assembled at runtime (`sql.raw`, string concatenation) or a writer method passed by + * reference is outside it. An aliased import of the table symbol, and a writer method named + * without being called, are both refused outright rather than missed. + * 4. CASCADES ARE READ FROM `server/drizzle/*.sql`, which is what the database runs. A schema + * edit with no migration is a different defect, and this guard does not look for it. + */ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { auditEventTypes } from "../src/audit"; +import { sourceFiles, stripComments } from "./support/source-scan"; + +const REPO = join(import.meta.dir, "../.."); + +/** TypeScript trees that can hold a write. Tests are excluded by file name below. */ +const TS_ROOTS = [ + "server/src", + "server/scripts", + "scripts", + "worker", + "shared", +]; +const MIGRATIONS = "server/drizzle"; + +type AuditEvent = (typeof auditEventTypes)[number]; + +type Disposition = + | { audited: AuditEvent; filedBy: string } + | { debt: string; note: string } + | { waived: string }; + +type Transition = { name: string } & Disposition; + +/* + * ─── THE LEDGER ────────────────────────────────────────────────────────────────────────────── + */ + +/** + * Every statement that writes the table, keyed ` :: `. + * + * A row whose transitions depend on who called it says so: `decidedAtCallers`. That is not a pass. + * It obliges every call of that function to appear in {@link CALL_SITES} with its own disposition, + * and the guard enforces that. + */ +const STATEMENTS: Record< + string, + { decidedAtCallers: true } | { transitions: Transition[] } +> = { + // The claim INSERT and the ON CONFLICT re-claim of an expired or settled row are one statement. + // Their dispositions are recorded at the one call site. + "claimAuthorizationAttempt :: INSERT INTO pending_authorizations": { + decidedAtCallers: true, + }, + "recordAuthorizationMint :: .update(pendingAuthorizations)": { + decidedAtCallers: true, + }, + // One DELETE behind three different endings: two mint-failure clean-ups and the person's cancel. + "discardAuthorizationAttempt :: .delete(pendingAuthorizations)": { + decidedAtCallers: true, + }, + // The one statement behind failed, unreconciled and withdrawn; see DELEGATIONS. + "closeAuthorizationAttempt :: .update(pendingAuthorizations)": { + decidedAtCallers: true, + }, + "completeAuthorizationAttempt :: UPDATE pending_authorizations": { + decidedAtCallers: true, + }, + '0060_pending_authorization_person_keys.sql :: DELETE FROM "pending_authorizations"': + { + transitions: [ + { + name: "orphan purge before the person keys were added", + waived: + "A one-shot data migration that removes attempts whose actor or person row was ALREADY deleted. It is not a lifecycle event of any live attempt. There is no actor to attribute it to, and nobody can ever complete these rows: the person they would bind to does not exist. It runs at deploy, before the application exists to file anything. An event here would say that a migration ran, and the migration history already says that.", + }, + ], + }, +}; + +/** + * Store-internal delegation: `from` is called ONLY by the listed wrappers. Every call of a wrapper + * then needs a {@link CALL_SITES} row. If any other function calls `from`, that call is undeclared + * and fails. + */ +const DELEGATIONS: Record = { + closeAuthorizationAttempt: [ + "failAuthorizationAttempt", + "markAuthorizationUnreconciled", + "markAuthorizationWithdrawn", + ], +}; + +/** + * Every call of a writing method, keyed ` :: `, IN SOURCE ORDER. Each entry has an + * `anchor`: a string near the call that names the branch. The call nearest the anchor must be that + * ordinal, so two calls cannot swap transitions without the guard noticing. + */ +const CALL_SITES: Record< + string, + { anchor: string; transitions: Transition[] }[] +> = { + "server/src/plugins/routes.ts :: claimAuthorizationAttempt": [ + { + anchor: "if (!claim.claimed)", + transitions: [ + { + name: "claim (a person starts an authorization)", + debt: "B5e", + note: "The moment a signed-in person starts a consent leaves no row on the trail, so nothing records who started which attempt.", + }, + { + name: "expiry overwrite (re-claim silently replaces an expired or settled attempt)", + debt: "B5e", + note: "An attempt that expired while still live is never recorded as having ended. The next claim's ON CONFLICT DO UPDATE erases it.", + }, + ], + }, + ], + "server/src/plugins/routes.ts :: discardAuthorizationAttempt": [ + { + anchor: "arcade-refused-attempt-not-cleared", + transitions: [ + { + name: "discard after the vendor refused the mint", + debt: "B5e", + note: "A started attempt ends with only a console line on cleanup failure. A refusal on a consent lifecycle should be on the trail, as `mcp.callback_refused` and `session.refused` are elsewhere.", + }, + ], + }, + { + anchor: "arcade-mint-not-recorded", + transitions: [ + { + name: "discard after the mint succeeded but could not be recorded", + debt: "B5e", + note: "A flow is LIVE at the vendor and the only record is the `arcade-mint-not-recorded` console line. This is the strongest case for an event in the ledger.", + }, + ], + }, + { + anchor: "const cancelled = await store.discardAuthorizationAttempt", + transitions: [ + { + name: "withdrawn by the person (cancel)", + audited: "mcp.authorization_cancelled", + filedBy: "recordAuthorizationCancelled", + }, + ], + }, + ], + "server/src/plugins/routes.ts :: recordAuthorizationMint": [ + { + anchor: "authId: minted.authorizationId", + transitions: [ + { + name: "mint recorded (attempt bound to the vendor's authorization)", + debt: "B5e", + note: "The binding the return leg is checked against is written without a trail row. B5d (the ignored `false`) sits on the same call.", + }, + ], + }, + ], + "server/src/plugins/routes.ts :: failAuthorizationAttempt": [ + { + anchor: 'settled.status === "failed"', + transitions: [ + { + name: "failed (the vendor reported the consent failed)", + debt: "B5e", + note: "A terminal verdict on the attempt with no trail row.", + }, + ], + }, + { + anchor: "settled.userId !== attempt.vendorUserId", + transitions: [ + { + name: "failed (the consent completed for an identity that is not the attempt's)", + debt: "B5e", + note: "A return leg that came back bound to somebody else is the exact attribution failure this lifecycle exists to refuse, and the refusal leaves no row.", + }, + ], + }, + ], + "server/src/plugins/routes.ts :: markAuthorizationWithdrawn": [ + { + anchor: "if (withdrawn)", + transitions: [ + { + name: "withdrawn (context changed; the vendor withdrew the retired grant)", + debt: "B5e", + note: "Only indirectly audited. `clearWithdrawnAccount` files `mcp.account_disconnected` rows for what it cleared, but the verdict on the attempt itself is not filed.", + }, + ], + }, + ], + "server/src/plugins/routes.ts :: markAuthorizationUnreconciled": [ + { + anchor: "else await store.markAuthorizationUnreconciled", + transitions: [ + { + name: "unreconciled (context changed; withdrawing the retired grant was not confirmed)", + debt: "B5e", + note: "The case where a grant may still be live at the vendor, and the only record is a console line.", + }, + ], + }, + ], + "server/src/plugins/routes.ts :: completeAuthorizationAttempt": [ + { + anchor: "recorded = true", + transitions: [ + { + name: "completed", + audited: "mcp.account_connected", + filedBy: "completeAuthorizationAttempt", + }, + ], + }, + ], +}; + +/** Every foreign key that deletes rows of this table when its parent goes, keyed by constraint. */ +const CASCADES: Record = { + pending_authorizations_server_id_mcp_servers_id_fk: { + name: "cascade delete when the app's server row is removed", + debt: "B5e", + note: "`removeServer` files `configuration.changed` (`mcp_server_removed`) and names the grants it released. It does not name an in-flight attempt, which may already be consented at the vendor.", + }, + pending_authorizations_actor_id_users_id_fk: { + name: "cascade delete when the attempt's actor is deleted", + debt: "B5e", + note: "`retireConnectionsFor` files rows for the connections it retires. An attempt that dies with its actor, possibly minted and live at the vendor, is not among them.", + }, + pending_authorizations_user_id_users_id_fk: { + name: "cascade delete when the attempt's person (user_id) is deleted", + debt: "B5e", + note: "The same as the actor key, and a separate key, because nothing makes `user_id` equal to `actor_id`.", + }, +}; + +/** + * Transitions that WRITE NOTHING and so cannot be found by a scan. They are listed by hand, and + * each is checked only for still existing at its anchor. See boundary 2 in the file header. + */ +const NON_WRITES: Record< + string, + { file: string; anchor: string } & Transition +> = { + "cancel that lost the race to a completion": { + file: "server/src/plugins/routes.ts", + anchor: "That authorization finished before it could be cancelled", + name: "cancel refused because the attempt had already completed", + debt: "B2c", + note: "The person asked to retract consent and the consent stood. The 409 tells them, and the trail records nothing about the request. The discard deleted nothing, so no write site exists for this guard to find.", + }, +}; + +/** + * The most debt rows the ledger may hold: transitions plus cascades plus non-writes. This may only + * go DOWN. A fix that converts a row lowers it in the same diff. A new debt row has to raise it, + * and that edit is the record that somebody decided to add one. + */ +const DEBT_CEILING = 13; + +/* + * ─── THE SCAN ──────────────────────────────────────────────────────────────────────────────── + */ + +/** A named function, method or `const x = async` binding, i.e. what "enclosing function" means. */ +const DEFINITION = + /(?:\basync\s+(?:function\s*\*?\s*)?([A-Za-z_$][\w$]*)\s*\(|\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\b|function\b)|\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\()/g; + +type Definition = { name: string; at: number }; + +function definitionsIn(code: string): Definition[] { + const out: Definition[] = []; + for (const m of code.matchAll(DEFINITION)) { + const name = m[1] ?? m[2] ?? m[3]; + if (name && name !== "function") out.push({ name, at: m.index ?? 0 }); + } + return out; +} + +function enclosing(defs: Definition[], at: number): string { + let name = ""; + for (const def of defs) { + if (def.at > at) break; + name = def.name; + } + return name; +} + +/** From a definition's header to the next definition: the text the function's body lives in. */ +function spanOf(code: string, defs: Definition[], name: string): string { + const index = defs.findIndex((def) => def.name === name); + if (index === -1) return ""; + return code.slice(defs[index].at, defs[index + 1]?.at ?? code.length); +} + +function lineAt(code: string, at: number): number { + return code.slice(0, at).split("\n").length; +} + +/** Not a test, not a declaration file. */ +function isSource(path: string): boolean { + return !/\.(test|spec)\.tsx?$|\.d\.ts$/.test(path); +} + +type Scanned = { path: string; code: string; defs: Definition[] }; + +function scanTs(): Scanned[] { + const out: Scanned[] = []; + for (const root of TS_ROOTS) { + const full = join(REPO, root); + if (!existsSync(full)) continue; + for (const file of sourceFiles(full, [".ts", ".tsx"])) { + if (!isSource(file) || file.includes("/node_modules/")) continue; + const code = stripComments(readFileSync(file, "utf8")); + out.push({ + path: file.slice(REPO.length + 1), + code, + defs: definitionsIn(code), + }); + } + } + return out; +} + +/** The SQL of every migration with `--` line comments blanked, per file. */ +function scanSql(): { file: string; sql: string }[] { + return sourceFiles(join(REPO, MIGRATIONS), [".sql"]).map((file) => ({ + file: basename(file), + sql: readFileSync(file, "utf8").replace(/--(?!>)[^\n]*/g, ""), + })); +} + +/** A write through the drizzle symbol, a raw statement or an interpolated symbol. */ +const TS_WRITE = + /\.\s*(insert|update|delete)\s*\(\s*pendingAuthorizations\s*\)|\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE(?:\s+TABLE)?)\s+(?:"?pending_authorizations"?|\$\{\s*pendingAuthorizations\s*\})(?![\w])/gi; + +const SQL_WRITE = + /\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM|TRUNCATE(?:\s+TABLE)?)\s+("?pending_authorizations"?)(?![\w])/gi; + +/** How an interpolated table symbol is spelled in a key: the `${…}` of a `sql` template. */ +const INTERPOLATED = `$${"{"}pendingAuthorizations}`; + +/** The ledger key of one TypeScript write: the enclosing function and the write, normalized. */ +function tsWriteKey(defs: Definition[], match: RegExpMatchArray): string { + const expression = match[1] + ? `.${match[1].toLowerCase()}(pendingAuthorizations)` + : `${match[2].toUpperCase().replace(/\s+/g, " ")} ${ + match[0].includes(INTERPOLATED.slice(0, 2)) + ? INTERPOLATED + : "pending_authorizations" + }`; + return `${enclosing(defs, match.index ?? 0)} :: ${expression}`; +} + +function findWriteStatements( + files: Scanned[], + migrations: { file: string; sql: string }[], +): string[] { + const out: string[] = []; + for (const { code, defs } of files) { + for (const match of code.matchAll(TS_WRITE)) + out.push(tsWriteKey(defs, match)); + } + for (const { file, sql } of migrations) { + for (const match of sql.matchAll(SQL_WRITE)) { + // CREATE TABLE and ALTER TABLE are DDL; only DML is a transition. + out.push( + `${file} :: ${match[1].toUpperCase().replace(/\s+/g, " ")} ${match[2]}`, + ); + } + } + return out.sort(); +} + +/** Every `ON DELETE` other than `restrict`/`no action` on a foreign key OF this table. */ +function findCascades(migrations: { file: string; sql: string }[]): string[] { + const live = new Set(); + for (const { sql } of migrations) { + for (const stmt of sql.split(/;|-->\s*statement-breakpoint/)) { + const add = + /ALTER\s+TABLE\s+"?pending_authorizations"?\s+ADD\s+CONSTRAINT\s+"([^"]+)"\s+FOREIGN\s+KEY[\s\S]*ON\s+DELETE\s+(cascade|set\s+null|set\s+default)/i.exec( + stmt, + ); + if (add) live.add(add[1]); + const drop = + /ALTER\s+TABLE\s+"?pending_authorizations"?\s+DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?"([^"]+)"/i.exec( + stmt, + ); + if (drop) live.delete(drop[1]); + // A foreign key declared inline in CREATE TABLE. + if (/CREATE\s+TABLE\s+"?pending_authorizations"?/i.test(stmt)) { + for (const inline of stmt.matchAll( + /CONSTRAINT\s+"([^"]+)"\s+FOREIGN\s+KEY[^,]*ON\s+DELETE\s+(?:cascade|set\s+null|set\s+default)/gi, + )) { + live.add(inline[1]); + } + } + } + } + return [...live].sort(); +} + +/** Writer method names whose calls are transitions: statement sites decided at callers, plus wrappers. */ +function writerMethods(): string[] { + const names = new Set(); + for (const [key, entry] of Object.entries(STATEMENTS)) { + if ("decidedAtCallers" in entry) names.add(key.split(" :: ")[0]); + } + for (const [from, via] of Object.entries(DELEGATIONS)) { + names.delete(from); + for (const wrapper of via) names.add(wrapper); + } + return [...names].sort(); +} + +type Call = { path: string; method: string; line: number; enclosing: string }; + +/** + * Every call of `names`, and every non-call mention of them (a method passed by reference is + * a write the ledger cannot follow, so it is reported rather than missed). + */ +function findCalls( + files: Scanned[], + names: string[], +): { calls: Call[]; references: string[] } { + const calls: Call[] = []; + const references: string[] = []; + for (const { path, code, defs } of files) { + for (const method of names) { + for (const m of code.matchAll( + new RegExp(`(? + "transitions" in entry ? entry.transitions : [], + ), + ...Object.values(CALL_SITES).flatMap((sites) => + sites.flatMap((site) => site.transitions), + ), + ...Object.values(CASCADES), + ...Object.values(NON_WRITES), + ]; +} + +/* + * ─── THE CHECKS ────────────────────────────────────────────────────────────────────────────── + */ + +/** How far, in lines, an anchor or a filer call may sit from the write it belongs to. */ +const ANCHOR_WINDOW = 20; + +const files = scanTs(); +const migrations = scanSql(); + +test("every write statement against pending_authorizations is in the ledger, and every ledger statement still exists", () => { + expect(findWriteStatements(files, migrations)).toEqual( + Object.keys(STATEMENTS).sort(), + ); +}); + +test("the table symbol is never imported under another name, which would hide its writes", () => { + const aliased = files + .filter(({ code }) => + /\bpendingAuthorizations\s+as\s+[\w$]+|=\s*pendingAuthorizations\s*[;,\n]/.test( + code, + ), + ) + .map(({ path }) => path); + expect(aliased).toEqual([]); +}); + +test("every cascade that deletes attempts is in the ledger", () => { + expect(findCascades(migrations)).toEqual(Object.keys(CASCADES).sort()); +}); + +test("store-internal delegation goes only through the declared wrappers", () => { + for (const [from, via] of Object.entries(DELEGATIONS)) { + const { calls } = findCalls(files, [from]); + expect(calls.map((call) => call.enclosing).sort()).toEqual([...via].sort()); + } +}); + +test("every call of a writing method has a disposition, in source order, at its anchor", () => { + const methods = writerMethods(); + const { calls, references } = findCalls(files, methods); + // A writer passed by reference escapes the call-site count; refuse it rather than miss it. + expect(references).toEqual([]); + + const grouped = new Map(); + for (const call of calls) { + const key = `${call.path} :: ${call.method}`; + grouped.set(key, [...(grouped.get(key) ?? []), call]); + } + expect([...grouped.keys()].sort()).toEqual(Object.keys(CALL_SITES).sort()); + + for (const [key, sites] of Object.entries(CALL_SITES)) { + const found = (grouped.get(key) ?? []).sort((a, b) => a.line - b.line); + expect({ key, count: found.length }).toEqual({ key, count: sites.length }); + const [path] = key.split(" :: "); + const lines = (files.find((file) => file.path === path)?.code ?? "").split( + "\n", + ); + sites.forEach((site, ordinal) => { + expect({ + key, + ordinal, + transitions: site.transitions.length > 0, + }).toEqual({ + key, + ordinal, + transitions: true, + }); + // The call NEAREST to some occurrence of the anchor must be this ordinal. + const nearestToAnchor = lines.flatMap((text, index) => { + if (!text.includes(site.anchor)) return []; + const line = index + 1; + const nearest = found.reduce( + (best, call, i) => + Math.abs(call.line - line) < Math.abs(found[best].line - line) + ? i + : best, + 0, + ); + return Math.abs(found[nearest].line - line) <= ANCHOR_WINDOW + ? [nearest] + : []; + }); + expect({ + key, + ordinal, + anchor: site.anchor, + at: nearestToAnchor.includes(ordinal), + }).toEqual({ key, ordinal, anchor: site.anchor, at: true }); + + // An audited transition whose filer is not the writer must call the filer beside the write. + for (const transition of site.transitions) { + if (!("audited" in transition)) continue; + if (transition.filedBy === key.split(" :: ")[1]) continue; + const call = found[ordinal]; + const window = lines + .slice(Math.max(0, call.line - 1), call.line - 1 + ANCHOR_WINDOW) + .join("\n"); + expect({ + transition: transition.name, + filerCalledAfterWrite: new RegExp( + `(? { + for (const transition of dispositionsOf()) { + if (!("audited" in transition)) continue; + expect(auditEventTypes).toContain(transition.audited); + const filers = files.filter(({ defs }) => + defs.some((def) => def.name === transition.filedBy), + ); + const filesIt = filers.map(({ code, defs }) => + spanOf(code, defs, transition.filedBy).includes( + `eventType: "${transition.audited}"`, + ), + ); + expect({ + transition: transition.name, + filesIt: filesIt.includes(true), + }).toEqual({ + transition: transition.name, + filesIt: true, + }); + } +}); + +test("a waiver argues, and a debt names its finding", () => { + const all = dispositionsOf(); + // A reason, not a label. "unaudited", "todo" and "n/a" are debt pretending to be judgement. + const thinWaivers = all.flatMap((t) => + "waived" in t && + (t.waived.length <= 80 || + /^(todo|n\/?a|unaudited|none|tbd)\b/i.test(t.waived)) + ? [t.name] + : [], + ); + expect(thinWaivers).toEqual([]); + const unnamedDebts = all.flatMap((t) => + "debt" in t && (!/^[A-Z]\d+[a-z]?$/.test(t.debt) || t.note.length <= 20) + ? [t.name] + : [], + ); + expect(unnamedDebts).toEqual([]); +}); + +test("debt only goes down", () => { + const debts = dispositionsOf().filter((t) => "debt" in t).length; + // Equality, not <=: a converted row must lower the ceiling in the same diff, so it cannot + // quietly make room for a new debt elsewhere. + expect(debts).toBe(DEBT_CEILING); +}); + +test("every hand-listed non-write still exists where the ledger says", () => { + for (const [key, entry] of Object.entries(NON_WRITES)) { + const code = files.find((file) => file.path === entry.file)?.code ?? ""; + expect({ key, present: code.includes(entry.anchor) }).toEqual({ + key, + present: true, + }); + } +}); + +/* + * ─── THE SCANNER'S OWN CONTROLS ────────────────────────────────────────────────────────────── + * The checks above prove the ledger matches the tree. These prove the scanner would SEE a write + * spelled each way it claims to, so a green above cannot mean "the pattern matches nothing". + */ + +test("the scanner sees every spelling of a write it claims to, and no read", () => { + const plant = (code: string): Scanned => ({ + path: "planted.ts", + code, + defs: definitionsIn(code), + }); + const writes = [ + "async a() { await db.insert(pendingAuthorizations).values({}); }", + "async b() { await db\n .update( pendingAuthorizations )\n .set({}); }", + "async c() { await tx.delete(pendingAuthorizations); }", + "async d() { await db.execute(sql`INSERT INTO pending_authorizations (x) VALUES (1)`); }", + "async e() { await db.execute(sql`update pending_authorizations set x = 1`); }", + 'async f() { await db.execute(sql`DELETE FROM "pending_authorizations" WHERE 1`); }', + `async g() { await db.execute(sql\`DELETE FROM ${INTERPOLATED} WHERE 1\`); }`, + "const h = async () => { await db.execute(sql`TRUNCATE pending_authorizations`); };", + ]; + expect(findWriteStatements(writes.map(plant), [])).toEqual([ + "a :: .insert(pendingAuthorizations)", + "b :: .update(pendingAuthorizations)", + "c :: .delete(pendingAuthorizations)", + "d :: INSERT INTO pending_authorizations", + "e :: UPDATE pending_authorizations", + "f :: DELETE FROM pending_authorizations", + `g :: DELETE FROM ${INTERPOLATED}`, + "h :: TRUNCATE pending_authorizations", + ]); + const reads = [ + "async r() { await db.select().from(pendingAuthorizations); }", + "async s() { await db.execute(sql`SELECT * FROM pending_authorizations`); }", + "async t() { await db.update(pendingAuthorizationsArchive).set({}); }", + "async u() { await db.execute(sql`UPDATE pending_authorizations_log SET x = 1`); }", + ]; + expect(findWriteStatements(reads.map(plant), [])).toEqual([]); + expect( + findWriteStatements( + [], + [ + { + file: "9999_x.sql", + sql: 'CREATE TABLE "pending_authorizations" (x text);\nDELETE FROM "pending_authorizations" WHERE true;', + }, + ], + ), + ).toEqual(['9999_x.sql :: DELETE FROM "pending_authorizations"']); +}); + +test("the cascade scan sees an added cascade and forgets a dropped one", () => { + expect( + findCascades([ + { + file: "1.sql", + sql: 'ALTER TABLE "pending_authorizations" ADD CONSTRAINT "k1" FOREIGN KEY ("a") REFERENCES "public"."x"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint\nALTER TABLE "pending_authorizations" ADD CONSTRAINT "k2" FOREIGN KEY ("b") REFERENCES "public"."y"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint\nALTER TABLE "pending_authorizations" ADD CONSTRAINT "k3" FOREIGN KEY ("c") REFERENCES "public"."z"("id") ON DELETE restrict ON UPDATE no action;', + }, + { + file: "2.sql", + sql: 'ALTER TABLE "pending_authorizations" DROP CONSTRAINT "k1";', + }, + ]), + ).toEqual(["k2"]); +}); + +test("a writer called, passed by reference, or defined are told apart", () => { + const code = [ + "async discardAuthorizationAttempt(input) {}", + "await store.discardAuthorizationAttempt({ a: 1 });", + "await store\n .discardAuthorizationAttempt({ a: 1 });", + "promise.then(store.discardAuthorizationAttempt);", + ].join("\n"); + const { calls, references } = findCalls( + [{ path: "p.ts", code, defs: definitionsIn(code) }], + ["discardAuthorizationAttempt"], + ); + expect(calls.map((call) => call.line)).toEqual([2, 4]); + expect(references).toEqual(["p.ts:5 discardAuthorizationAttempt"]); +}); From a54ba3a3b127493236e832d2133d545b8a3a47b2 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:16:02 +0200 Subject: [PATCH 214/232] test(levers): let two existing guards see the stubBroker factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both guards were correct and both were telling the truth: L3 introduced a broker shape neither had been taught, and each said so rather than passing quietly. Each lever was green on its own branch; the failures appeared only once merged -- CC-D again, caught this time by the binding post-merge run. broker-authorization-pairing: its `unreachedIn` net exists because `literalsIn` anchors on `authorize`, so a hand-written literal declaring grantIsLocal + authorizations without one would slip past unexamined and read as a pass. A `stubBroker({ … })` without `authorize` is the opposite case: a member the factory is not given does not answer a plausible shape, it records the call and THROWS, so it cannot mint an id and there is no pairing to check. The exemption is narrow -- a stubBroker call that DOES provide `authorize` still carries that member on its own line, so `literalsIn` finds and examines it as it would any other literal. Verified the net still bites: a hand-written literal with grantIsLocal + authorizations and no `authorize`, planted in stub-broker.test.ts, failed with `+ "tests/stub-broker.test.ts:120"`. Removed; 31 pass / 0 fail. ts-expect-error-coverage: stub-broker.test.ts carries three directives and was not named in server/tsconfig.json's include, so `tsc` never checked them -- an uncovered directive asserts nothing while reading as coverage, which is exactly what that guard exists to prevent. Named it, one file and not the directory, following the convention the tsconfig's own comments set out. It type-checks clean now that tsc can see it, so the three directives are real. typecheck 0, typecheck:tests clean against baseline 264, lint 0, format:check 0, both guards 33 pass / 0 fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../broker-authorization-pairing.test.ts | 21 ++++++++++++++++--- server/tsconfig.json | 9 +++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/server/tests/broker-authorization-pairing.test.ts b/server/tests/broker-authorization-pairing.test.ts index df1ae6f6a..8f029197e 100644 --- a/server/tests/broker-authorization-pairing.test.ts +++ b/server/tests/broker-authorization-pairing.test.ts @@ -174,11 +174,26 @@ function unreachedIn(file: string): string[] { lines.forEach((line, at) => { const m = /^(\s*)grantIsLocal\s*:\s*(true|false)\s*[,}]/.exec(line); if (!m) return; - const { named } = blockAt(lines, at, m[1]!.length); + const { from, named } = blockAt(lines, at, m[1]!.length); // `{ key, grantIsLocal: true }` in a deployment config is not a broker. if (named("authorizations") < 0) return; - if (named("authorize") < 0) - out.push(`${file.slice(ROOT.length + 1)}:${at + 1}`); + if (named("authorize") >= 0) return; + /* + * A `stubBroker({ … })` WITHOUT an `authorize` IS NOT UNEXAMINED, it is unable to mint. + * + * This net exists because `literalsIn` anchors on `authorize`, so a hand-written literal that + * declared the pair without one would slip past unchecked and read as a pass. The factory + * inverts that risk: a member it is not given does not quietly answer a plausible shape, it + * RECORDS THE CALL AND THROWS. So an omitted `authorize` here cannot mint an id, there is no + * pairing to check, and flagging it would demand the caller write out a member whose whole + * purpose is to be absent. + * + * The exemption is narrow on purpose: a `stubBroker` call that DOES provide `authorize` still + * carries that member on its own line, so `literalsIn` finds and examines it exactly as it + * does any other literal. Only the cannot-mint case is skipped. + */ + if (/\bstubBroker\s*\(/.test(lines[from - 1] ?? "")) return; + out.push(`${file.slice(ROOT.length + 1)}:${at + 1}`); }); return out; } diff --git a/server/tsconfig.json b/server/tsconfig.json index 4dfaa97cd..e599cf894 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -46,6 +46,13 @@ // `@ts-expect-error` that says so is checked by `tsc` or by nothing at all. "tests/composio-grants-for.test.ts", "tests/arcade-transport-registry.test.ts", - "tests/db-client-address.test.ts" + "tests/db-client-address.test.ts", + // `tests/stub-broker.test.ts` FOR THE SAME REASON, and it is the reason `stubBroker` exists: + // the factory's promise is that a broker built through it carries NO cast, so a member added to + // `ConnectedAppBroker` breaks the stub here instead of silently becoming `any`. Three + // `@ts-expect-error`s state that promise — a stub missing `grantIsLocal`, one missing + // `authorizations`, and one whose `revokeAccount` answers the wrong type must each fail to + // compile. Checked by `tsc` or by nothing at all. + "tests/stub-broker.test.ts" ] } From c78fe76efa223284d19c56b48a953c2e5a7b61af Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:45:42 +0200 Subject: [PATCH 215/232] rehome(1/2): arcade store methods and shared contracts into main's split layout Re-homes the store half of the arcade work that 16866075 dropped by taking main's side of store.ts / composio-adapter.ts / index.ts. Source of truth is a54ba3a3; every hunk of `git diff bb62c653 a54ba3a3 -- store.ts` was placed in the plugin-store module that now owns its function (60/67 hunks by exact text match; the 7 others are imports plus the two helpers and constants below). No behaviour change intended. - plugin-store/connections.ts: the 13 authorization/sibling/withdrawal methods + our edits to existing connection methods - plugin-store/skills.ts: serverPublishesAction - plugin-store/servers.ts: our edits to server methods; exposes brokerProvider - plugin-store/context.ts: brokerProvider, closeAuthorizationAttempt, resolveSiblings, clearEndedSiblings (the one home of the cascade rules) - plugin-store/model.ts: PluginStoreOptions.brokerProvider, pending attempt types/helpers, connectionSiblings, BROKERED_ID_PREFIXES/SCREEN_NAMES - store.ts shim re-exports connectionSiblings, ConnectionFootprint, BrokeredDisconnectReason, PendingAuthorization - composio-vendor.ts / composio-client/*: scopes, AccountRow, configIdOf, BrokerApp nulls; consent.ts gains grantIsLocal, authorizations, authProviderOf, grantsFor and authorizationId (consentMethods now takes the client's actions, passed from composio-adapter.ts) - index.ts: provider wiring, brokerProvider, backfillBrokeredLogos - routes.ts / route-groups/context.ts: composio option carries provider; route-groups: BrokerUnconfiguredError / brokerReturnUrl call sites given store.brokerProvider (typecheck only; bodies are stage 2's) Path-keyed guards updated (paths moved, counts unchanged): sole-definition, broker-authorization-pairing, mutation-register. Line-shift re-keys from the main merge: typecheck-baseline (40), assertion-shape (4). Co-Authored-By: Claude Opus 5.5 --- scripts/mutation-register.ts | 8 +- server/src/index.ts | 93 +- server/src/plugins/composio-adapter.ts | 2 +- server/src/plugins/composio-client/consent.ts | 143 ++- server/src/plugins/composio-client/context.ts | 9 +- server/src/plugins/composio-client/model.ts | 3 +- server/src/plugins/composio-vendor.ts | 78 +- .../src/plugins/plugin-store/connections.ts | 982 +++++++++++++++++- server/src/plugins/plugin-store/context.ts | 338 +++++- server/src/plugins/plugin-store/model.ts | 205 +++- server/src/plugins/plugin-store/servers.ts | 442 ++++++-- server/src/plugins/plugin-store/skills.ts | 36 + .../src/plugins/route-groups/composio-apps.ts | 10 +- .../src/plugins/route-groups/connections.ts | 12 +- server/src/plugins/route-groups/context.ts | 3 +- server/src/plugins/routes.ts | 3 +- server/src/plugins/store.ts | 4 + server/tests/assertion-shape.test.ts | 8 +- .../broker-authorization-pairing.test.ts | 4 +- server/tests/sole-definition.test.ts | 29 +- server/tests/typecheck-baseline.txt | 80 +- 21 files changed, 2249 insertions(+), 243 deletions(-) diff --git a/scripts/mutation-register.ts b/scripts/mutation-register.ts index 6fa356d31..f30a16d67 100644 --- a/scripts/mutation-register.ts +++ b/scripts/mutation-register.ts @@ -98,7 +98,7 @@ export const REGISTER: MutationEntry[] = [ { id: "composio-revoke-asks-under-local-id", kind: "identity", - path: "server/src/plugins/composio-adapter.ts", + path: "server/src/plugins/composio-client/consent.ts", anchor: "async revoke({ account, toolkit }): Promise {", find: "const userId = account.vendorUserId;", replace: "const userId = account.userId;", @@ -108,7 +108,7 @@ export const REGISTER: MutationEntry[] = [ { id: "composio-is-connected-asks-under-local-id", kind: "identity", - path: "server/src/plugins/composio-adapter.ts", + path: "server/src/plugins/composio-client/consent.ts", anchor: "async isConnected({ account, toolkit }): Promise {", find: "const userId = account.vendorUserId;", replace: "const userId = account.userId;", @@ -177,7 +177,7 @@ export const REGISTER: MutationEntry[] = [ { id: "composio-account-listing-unscoped-by-person", kind: "query-scope", - path: "server/src/plugins/composio-adapter.ts", + path: "server/src/plugins/composio-client/context.ts", find: "userIds: [userId],", replace: "", tests: ["server/tests/composio-adapter.test.ts"], @@ -199,7 +199,7 @@ export const REGISTER: MutationEntry[] = [ { id: "disconnect-never-asks-the-vendor", kind: "call-made", - path: "server/src/plugins/store.ts", + path: "server/src/plugins/plugin-store/connections.ts", find: "const ended = await broker.revoke({", replace: "const ended = await ((_skipped: unknown) => Promise.resolve(false))({", diff --git a/server/src/index.ts b/server/src/index.ts index 1a519b93c..225af16a3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,7 +6,7 @@ import { import { COMPUTER_GUIDANCE } from "@openbot/shared/bot-prompt"; import { workOwner } from "@openbot/shared/work-owner"; import { createTurnRunner } from "@openbot/toolkit/run-turn"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { createActivityStore } from "./activity/activity"; import { @@ -130,6 +130,7 @@ import { } from "./credentials"; import { createDatabase } from "./db/client"; import { + brokeredConnections, channelAgents, channelMemberships, channels, @@ -185,15 +186,23 @@ import { createPasswordStore } from "./passwords/store"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { createAccountModeSwitch } from "./plugins/account-mode"; +import { assertActivatable, providerDecision } from "./plugins/broker"; +import { BROKERED_PROFILE } from "./plugins/access"; import { useRoutineTools } from "./plugins/builtin-routines"; +import { useArcadeClient } from "./plugins/arcade"; +import { createArcadeClient } from "./plugins/arcade-adapter"; import { useComposioClient } from "./plugins/composio"; import { createComposioClient } from "./plugins/composio-adapter"; -import { backfillComposioLogos } from "./plugins/logos"; +import { backfillBrokeredLogos } from "./plugins/logos"; import { redirectUriFor } from "./plugins/oauth"; import { createSharedUseGate, createSharedUseStore, } from "./plugins/shared-use-store"; +import { + selectProviderWiring, + storedProvider, +} from "./plugins/provider-wiring"; import { createPluginStore } from "./plugins/store"; import { grantedSkills, @@ -769,10 +778,35 @@ const sandboxedStore = createSandboxedStore(database, bootAuditStore); * Installing a client built from an absent key would turn all of that into a vendor error at first * use, which sends an operator looking for a broken Composio instead of at their own configuration. */ -const composio = config.composioApiKey - ? createComposioClient(config.composioApiKey) - : null; -if (composio) useComposioClient(composio.actions); +/* + * EXACTLY ONE PROVIDER, DECIDED ONCE. `selectProviderWiring` answers which transport client is + * installed and which broker goes on to the store and the routes; the other client is installed as + * an explicit `null`, so a deployment holding both keys (legal, and needed to end accounts at the + * old vendor) never has two seams live. It refuses, rather than wiring nothing, both configurations + * it cannot decide: a named provider whose key is unset, and both keys set with nothing named. + */ +const providerWiring = selectProviderWiring(config, { + composio: createComposioClient, + arcade: createArcadeClient, +}); +useComposioClient(providerWiring.install.composio); +useArcadeClient(providerWiring.install.arcade); +const wiredProvider = providerWiring.provider; +const composio = + wiredProvider && providerWiring.broker + ? { broker: providerWiring.broker, provider: wiredProvider } + : null; +/* + * Activation is refused while the OTHER provider still holds connection rows. Keyed on rows, never + * on keys: having both keys set is the expected state while a sunset is in progress. + */ +if (wiredProvider) { + const [{ count: otherProviderRows }] = await database + .select({ count: sql`count(*)::int` }) + .from(brokeredConnections) + .where(ne(brokeredConnections.provider, wiredProvider)); + assertActivatable(wiredProvider, otherProviderRows); +} /** * The private sign-in form and the Passwords vault. A Bot asks to be signed in; the person answers @@ -835,7 +869,41 @@ const signInService = computerGateway * module scope because the publication, assignment and grant routes below also need to reapprove * or refile against it when a Bot's exposure changes. */ -const sharedUseStore = createSharedUseStore(database); +/* + * WHICH SLICE of `brokered_connections` this deployment reads, decided ONCE, at the one boundary + * that can see both the configuration and the rows. + * + * THERE IS NO `?? "composio"` HERE ANY MORE, AND THAT WAS A BUG RATHER THAN A SHORTHAND. + * Configuration names no provider for three different reasons, and the one that matters is "both + * keys set, OPENBOT_BROKER_PROVIDER unset" — legal, documented, and exactly what a deployment + * migrating off Composio sits in while it ends its old accounts. Defaulting it to `composio` gave + * the plugin store and the shared-use store a filter that excludes every Arcade row this deployment + * holds, so live Arcade connections read as absent and the accounts behind them could not be + * revoked. `storedProvider` takes the three causes apart and, where configuration is silent, lets + * the rows name themselves. The shared-use store and the plugin store are both given this one + * value, so the gate and the approvals it reads agree. + */ +const providerConfiguration = providerDecision(config); +const brokerProvider = storedProvider( + providerConfiguration, + ( + await database + .selectDistinct({ provider: brokeredConnections.provider }) + .from(brokeredConnections) + ).map((row) => row.provider), +); +if (providerConfiguration.kind === "ambiguous") { + /* + * Said out loud, once, at startup: this deployment has two keys and no answer, so nothing is + * active and nothing can be connected or revoked until one is named. The rows it already holds + * are read as their own provider's (above), which is why this is a warning and not a refusal — + * the screens stay honest, the vendor calls stay impossible. + */ + console.warn( + `${providerConfiguration.configured.map((provider) => BROKERED_PROFILE[provider].envVar).join(" and ")} are both set and OPENBOT_BROKER_PROVIDER names neither, so no broker is active: connected apps can be neither connected nor revoked until it is set. Existing connections are read as ${BROKERED_PROFILE[brokerProvider].vendor}'s.`, + ); +} +const sharedUseStore = createSharedUseStore(database, brokerProvider); const pluginStore = createPluginStore({ database, @@ -867,6 +935,7 @@ const pluginStore = createPluginStore({ * attempted. */ broker: composio?.broker, + brokerProvider, }); /* @@ -884,9 +953,13 @@ const accountModes = createAccountModeSwitch({ // Logo metadata is optional; a vendor outage must not prevent the API from starting. if (composio) { - void backfillComposioLogos(database, composio.broker).catch(() => { + void backfillBrokeredLogos( + database, + composio.provider, + composio.broker, + ).catch(() => { console.warn( - "Composio app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.", + `${BROKERED_PROFILE[composio.provider].vendor} app logos could not be updated. Existing icons remain available; missing logos will be retried on the next restart.`, ); }); } @@ -2397,7 +2470,7 @@ const app = createApp( // The app directory, behind the same client the plugin store and the transport already share. // Absent without a key, which leaves the routes reporting no broker rather than listing apps // nobody could connect. - composio ? { broker: composio.broker } : undefined, + composio ?? undefined, process.env.OPENBOT_MODEL_OAUTH_FILE?.trim() ? createProviderOAuthProxy(process.env.OPENBOT_MODEL_OAUTH_FILE.trim()) : undefined, diff --git a/server/src/plugins/composio-adapter.ts b/server/src/plugins/composio-adapter.ts index 716db9e4c..5cba2e202 100644 --- a/server/src/plugins/composio-adapter.ts +++ b/server/src/plugins/composio-adapter.ts @@ -109,7 +109,7 @@ export function buildComposioClient( const broker: ComposioBroker = { ...directoryMethods(context), - ...consentMethods(context), + ...consentMethods(context, actions), ...fieldMethods(context), ...accountMethods(context), }; diff --git a/server/src/plugins/composio-client/consent.ts b/server/src/plugins/composio-client/consent.ts index eba5bb582..e14943ace 100644 --- a/server/src/plugins/composio-client/consent.ts +++ b/server/src/plugins/composio-client/consent.ts @@ -1,6 +1,12 @@ -import { BrokerRefusalError, type ComposioBroker } from "../broker"; +import { + BrokerRefusalError, + type ComposioBroker, + type GrantState, +} from "../broker"; +import { type ComposioActions, LISTING_LIMIT } from "../composio"; import { askVendor, + configIdOf, everyRefusal, named, sent, @@ -11,18 +17,130 @@ import { withdrawableAccounts, withdrawalDeclined, } from "../composio-vendor"; +import type { AccountRef } from "../shared-accounts"; import type { ClientContext } from "./context"; import { CONFIG_SUFFIX, CONNECTED, REVOCABLE, STATUSES_NAMED } from "./model"; -/** Sending a person to consent, asking whether they are connected, and revoking. */ -export function consentMethods(context: ClientContext) { +/** + * Sending a person to consent, asking whether they are connected and what they hold, and revoking. + * + * `actions` is the same client's action seam, read by {@link ComposioBroker.grantsFor} for the + * actions it reports a grant over. + */ +export function consentMethods( + context: ClientContext, + actions: ComposioActions, +) { const { vendor, accountsFor, configsFor, askForEach } = context; return { + // Composio's grant is held by this deployment. Its existing behaviour, declared; see + // {@link ConnectedAppBroker.grantIsLocal} for why this is a property and not a comparison. + grantIsLocal: true, + // Composio runs no correlated, pollable authorization flow: it mints nothing to poll, and its + // return leg is an OAuth callback with state, handled in `oauth.ts` and never through this + // seam. Declared rather than omitted; see {@link ConnectedAppBroker.authorizations}. + authorizations: null, + /** + * The toolkit itself, WHICH IS AN ANSWER AND NOT A STUB. + * + * A Composio connection is made against an auth config, and an auth config is per toolkit. So + * there is no auth provider standing behind several toolkits here the way Arcade's `google` + * stands behind `Gmail` and `GoogleCalendar`: the toolkit IS its own auth provider, and the set + * of apps sharing a connection with it is consequently ALWAYS EMPTY. That is the truth about + * Composio rather than a degradation of it — {@link ConnectedAppBroker.grantsFor}'s "degenerate + * is not absent", one member along. + * + * SO IT DOES NOT THROW, and the reason is what a throw would cost the callers: every consumer + * of the sibling computation would need a presence test around this call, and the disconnect + * path would carry a provider branch — which is the vendor-name comparison this seam exists to + * keep out of permission and revocation code. + * + * No network: the answer is a property of how Composio is shaped, so nothing is asked. + */ + authProviderOf: async (toolkit: string) => toolkit, + /** + * What this person holds at this app, said once per action because that is what Composio knows. + * + * DEGENERATE AND NOT ABSENT, which is why this is implemented rather than thrown. An auth + * config fixes its scope set when it is created and a connection is made against the config, + * so there is exactly one answer for the toolkit: either this person has an account on it or + * they have not, and the scopes are the config's. Reporting that answer once per action is the + * honest rendering of a toolkit-wide grant through {@link GrantState}, which is this + * deployment's vocabulary rather than any vendor's row shape. The alternative — a throw — + * would make one seam member answer two different questions depending on who implemented it, + * which is the defect this file already carried at {@link ComposioBroker.accountName}. + * + * THE STATE IS MEASURED OVER EVERY CONFIG, AS {@link ComposioBroker.isConnected}'S IS. The two + * are the same read-only question — can this person act through the app — and the call that + * runs an action names the person and the toolkit, not the account, so an account on an + * operator's hand-made config is one they act through. Measuring this one over our configs + * alone would answer `needs-consent` to somebody the gate lets through, and send them to + * consent to an app they already use. Only {@link ComposioBroker.revoke}, which ACTS on what it + * finds, is scoped to ours. + * + * SO THE SCOPES ARE THE CONFIGS THIS PERSON'S ACCOUNTS WERE MINTED AGAINST, WHICH NEED NOT BE + * OURS. A config fixes its scope set when it is created and every account on it holds exactly + * that set, so an `authorized` answer carries the scopes of the configs the counted accounts + * sit on — every one of them, which is why the listing is read to the end rather than stopped at + * the first row. This used to carry THIS DEPLOYMENT'S scopes whatever the account sat on, which + * paired `authorized` with a scope set the person never granted. A `needs-consent` answer + * carries our configs' scopes instead, because those are the configs a consent from here is + * minted against: it describes what they would be asked for. + * + * AN ACCOUNT WHOSE CONFIG CANNOT BE NAMED, OR IS NOT AMONG THE CONFIGS THAT COULD BE READ, + * LENDS NO SCOPES — neither its own, which nothing here can read, nor ours, which it does not + * hold. The names are deduplicated and ordered so that two readings of one unchanged deployment + * are one answer. + */ + async grantsFor({ + toolkit, + account, + }: { + toolkit: string; + account: AccountRef; + }): Promise { + const connected = await accountsFor( + account.vendorUserId, + toolkit, + CONNECTED, + ); + const state = connected.length > 0 ? "authorized" : "needs-consent"; + + const { held, ours } = await configsFor(toolkit); + const mintedOn = new Set( + connected.map(configIdOf).filter((id) => id !== null), + ); + const granting = + state === "authorized" + ? held.filter((config) => mintedOn.has(config.id)) + : ours; + const scopes = [ + ...new Set(granting.flatMap((config) => config.scopes)), + ].sort(); + + const published = await actions.listActions(toolkit, { + limit: LISTING_LIMIT, + }); + // A fresh array per entry: one shared list would let a caller mutating its first grant's + // scopes rewrite every other grant in the answer. An authorized entry is always `held`: it is + // authorized only because this person holds an account, which is a consent they gave. + return published.map( + (action): GrantState => + state === "authorized" + ? { + action: action.slug, + state, + scopes: [...scopes], + authorization: "held", + } + : { action: action.slug, state, scopes: [...scopes] }, + ); + }, async authorize({ account, toolkit, returnUrl, - }): Promise<{ redirectUrl: string }> { + }): Promise<{ redirectUrl: string; authorizationId: string | null }> { const userId = account.vendorUserId; /* * THE CONFIG THIS DEPLOYMENT ALREADY MADE, AND NO SECOND ONE MADE HERE. @@ -291,7 +409,11 @@ export function consentMethods(context: ClientContext) { `Composio began a connection to ${toolkit} but answered with no page to visit, so there is nothing to send ${whose(account)} to. An app that is connected by entering a credential rather than by visiting a page cannot be connected from here.`, ); } - return { redirectUrl: page }; + // DECLARED NULL, NOT COMPOSIO'S CONNECTION-REQUEST ID. Composio's return leg never comes + // through this seam: it lands in `oauth.ts`, correlated by sealed state this deployment + // minted, not by an id the broker handed back. The connection-request id would put a value + // with a DIFFERENT MEANING behind the same member — the `accountName` defect again. + return { redirectUrl: page, authorizationId: null }; }, async isConnected({ account, toolkit }): Promise { @@ -578,5 +700,14 @@ export function consentMethods(context: ClientContext) { */ return ids.length > 0; }, - } satisfies Pick; + } satisfies Pick< + ComposioBroker, + | "grantIsLocal" + | "authorizations" + | "authProviderOf" + | "grantsFor" + | "authorize" + | "isConnected" + | "revoke" + >; } diff --git a/server/src/plugins/composio-client/context.ts b/server/src/plugins/composio-client/context.ts index 9953919a3..57f0d770b 100644 --- a/server/src/plugins/composio-client/context.ts +++ b/server/src/plugins/composio-client/context.ts @@ -1,6 +1,7 @@ import type { BrokerApp, BrokerRefusalError } from "../broker"; import { LISTING_LIMIT } from "../composio"; import { + type AccountRow, appOf, askVendor, type CheckedAuthConfig, @@ -34,8 +35,8 @@ export function createClientContext( * * AND SO ARE THE OTHER TWO THINGS THE TWO QUESTIONS DO NOT SHARE, for the same reason the * statuses are. `configs` is which authorization configs the answer is about — every one of them - * for the gate, and only this deployment's for the withdrawal, because the withdrawal ACTS on - * what it finds. `enough` is when the rows in hand already settle the question, which is true of + * for the three that only READ (the gate, `grantsFor` and `accountName`), and only this + * deployment's for the withdrawal, because the withdrawal ACTS on what it finds. `enough` is when the rows in hand already settle the question, which is true of * a boolean the moment one row arrives and never true of a set of accounts to end. Both are * written at the call sites below, beside the statuses, so that the whole of the difference * between the two questions is one argument list a reader can compare. @@ -46,9 +47,9 @@ export function createClientContext( statuses: VendorAccountStatus[], asked: { configs?: string[]; - enough?: (rows: { id?: unknown; data?: unknown }[]) => boolean; + enough?: (rows: AccountRow[]) => boolean; } = {}, - ): Promise<{ id?: unknown; data?: unknown }[]> => { + ): Promise => { /* * EVERY PAGE UNLESS THE CALLER SAYS OTHERWISE, READ HERE RATHER THAN AT EITHER CALLER, so that * the two questions cannot drift on the one thing they do share. A truncated listing is the diff --git a/server/src/plugins/composio-client/model.ts b/server/src/plugins/composio-client/model.ts index d2619357a..47cb41199 100644 --- a/server/src/plugins/composio-client/model.ts +++ b/server/src/plugins/composio-client/model.ts @@ -1,6 +1,7 @@ import type { BrokerApp, FieldScheme } from "../broker"; import type { ComposioResult } from "../composio"; import type { + AccountRow, CheckedAuthConfig, VendorAccountStatus, VendorAuthConfig, @@ -420,7 +421,7 @@ export type ComposioVendor = { * same function reads `response.auth_config.id` (`:60`) on its way to building each row, so a * row that is not an object raises inside the vendor's code and never reaches this listing. */ - items: { id?: unknown; data?: unknown }[]; + items: AccountRow[]; /** * The same truncation signal as the auth-config listing above, from the same vendor schema. * diff --git a/server/src/plugins/composio-vendor.ts b/server/src/plugins/composio-vendor.ts index cc8f4e9d4..9e29f74fd 100644 --- a/server/src/plugins/composio-vendor.ts +++ b/server/src/plugins/composio-vendor.ts @@ -178,8 +178,42 @@ export type VendorAuthConfig = { id?: unknown; name?: unknown; status?: unknown; + /** + * What the config was created to ask for, where Composio publishes it, and `unknown` because it + * is copied across verbatim like the three above. + * + * IT IS A PROPERTY OF THE CONFIG RATHER THAN OF AN ACCOUNT, which is the whole of why + * {@link ComposioBroker.grantsFor}'s answer is one state for a toolkit: the scope set is fixed + * when the config is created and every account connected against it holds exactly that set. + */ + scopes?: unknown; }; +/** + * One connected account as the listing hands it over, narrowed to the fields this file reads. + * + * `id` and `data` are read by the withdrawal and by {@link ComposioBroker.accountName}. + * `authConfig` is read by {@link ComposioBroker.grantsFor} alone, for the id of the config the + * account was minted against, because that config — and no other — fixes the scopes the account + * holds. `transformConnectedAccountResponse` builds it on every row from the raw + * `auth_config` (`@composio/core` 0.18.1, `src/utils/transformers/connectedAccounts.ts:55-62`), and + * it is `unknown` for the reason every other field here is: the transform is warn-only. + */ +export type AccountRow = { id?: unknown; data?: unknown; authConfig?: unknown }; + +/** + * The id of the auth config one account was minted against, or null where Composio did not name it. + * + * NULL RATHER THAN A REFUSAL, BECAUSE ITS ONE READER REPORTS RATHER THAN ACTS — the reasoning of + * {@link scopesOf}, one field along. An account whose config cannot be named is still an account, + * and what is lost is only the description of what it holds. + */ +export function configIdOf(row: AccountRow): string | null { + const config = row.authConfig; + if (!config || typeof config !== "object") return null; + return textOf((config as { id?: unknown }).id); +} + /** * The connected-account statuses this file knows how to ask for, as the literals the SDK admits. * @@ -1038,6 +1072,16 @@ export function appOf(row: VendorToolkit, position: number): BrokerApp { logo, categories, actionCount, + /* + * THREE EXPLICIT NULLS, AND NEVER `?? 0`. Composio's toolkit listing publishes no behaviour + * classification and no per-action scope sets, so this deployment has not measured them here — + * which is a different fact from having measured them and found none. A zero would say the + * second, and the page would fire its coverage warning on every Composio app: a false alarm at + * exactly the moment somebody is deciding whether to trust one. See {@link BrokerApp}. + */ + classifiedCount: null, + permissionCount: null, + scopedActionCount: null, connection: connectionOf(row), }; } @@ -1055,8 +1099,35 @@ export type CheckedAuthConfig = { id: string; name: string; status: unknown; + /** The scope set this config was created with, as {@link scopesOf} could read it. */ + scopes: string[]; }; +/** + * The scope names on an auth config row, and the EMPTY LIST for everything else. + * + * NOT A REFUSAL, BECAUSE ITS ONE READER REPORTS RATHER THAN ACTS. Every other field this file + * reads off a config decides something destructive or something a person is sent through — which + * id to delete, which config to mint a consent against — so a shape that cannot be read is a thing + * to stop on. Scopes are carried on a {@link GrantState}, which is a description handed to a page: + * Composio publishes none at all for a key-scheme config, so "no scope set published" is the + * ordinary case and not a fault, and an app somebody is plainly connected to must not become + * unreportable because the field arrived as a string. + * + * ONE PASS, AND THE NON-STRINGS ARE DROPPED RATHER THAN RENDERED. A scope is a name sent to a + * provider; `String({})` is not one, and a list half of whose entries were invented here is worse + * than a shorter list. + */ +function scopesOf(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const names: string[] = []; + for (const entry of value) { + const name = textOf(entry); + if (name !== null) names.push(name); + } + return names; +} + /** * That status as the WORD it is, so the test and the sentence about it are about the same value. * @@ -1198,7 +1269,12 @@ export function readableConfigs( return; } - const config: CheckedAuthConfig = { id, name, status: row.status }; + const config: CheckedAuthConfig = { + id, + name, + status: row.status, + scopes: scopesOf(row.scopes), + }; if (slot === undefined) { slotOf.set(id, settled.length); settled.push(config); diff --git a/server/src/plugins/plugin-store/connections.ts b/server/src/plugins/plugin-store/connections.ts index ab748bd12..cadf27f87 100644 --- a/server/src/plugins/plugin-store/connections.ts +++ b/server/src/plugins/plugin-store/connections.ts @@ -1,13 +1,16 @@ -import { and, asc, eq, inArray, sql } from "drizzle-orm"; -import { recordAuditEvent } from "../../audit"; +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { createAuditStore, recordAuditEvent } from "../../audit"; +import type { Database } from "../../db/client"; import { brokeredConnections, credentials as credentialRows, mcpServers, mcpTools, mcpUserCredentials, + pendingAuthorizations, users, } from "../../db/schema"; +import { BROKERED_PROFILE, toolkitOfFor } from "../access"; import { BrokerRefusalError, BrokerUnconfiguredError, @@ -18,17 +21,19 @@ import { import { asksForArguments, askAction as composioAskAction, - toolkitOf, VERSION_ARG, } from "../composio"; import type { AccountRef } from "../shared-accounts"; import type { StoreContext } from "./context"; import { + attemptNamed, type BrokeredProbe, IDENTITY_ACTION, iso, type OAuthClient, + type PendingAuthorization, PluginRefusedError, + pendingAuthorizationFromRow, } from "./model"; /** OAuth clients and a person's connections, including every brokered (Composio) connection flow. */ @@ -53,6 +58,10 @@ export function connectionMethods(context: StoreContext) { brokeredAppRowsAt, brokeredAppScheme, brokeredAppKind, + brokerProvider, + closeAuthorizationAttempt, + resolveSiblings, + clearEndedSiblings, } = context; return { /** @@ -359,6 +368,14 @@ export function connectionMethods(context: StoreContext) { * draw the deployment's shared account as one of them — a deployment row has no `user_id`, * and the filter keeps that a fact this query states rather than one it happens to rely on. */ + /* + * NO PROVIDER PREDICATE HERE, DELIBERATELY, AND THAT IS NOT AN OVERSIGHT. Each row is resolved + * below at the ACTIVE provider's canonical url, so another provider's row would be silently + * dropped from the page. What keeps that unreachable is the startup activation refusal + * (`assertActivatable`, called from `server/src/index.ts`): it will not boot when the + * configured provider differs from the one on the existing rows. The provider sunset flow + * relaxes that refusal, and whoever builds it must add the `brokerProvider` predicate here. + */ const connections = await database .select({ toolkit: brokeredConnections.app, @@ -421,13 +438,21 @@ export function connectionMethods(context: StoreContext) { * join answered and what the missing `serverId` below drops. A person can hold an account at * an app this deployment has since removed, and the settings page has no row to draw for it. */ + /* + * AT THE ACTIVE BROKER'S SCHEME, which is the one {@link brokeredAppRow} resolves every other + * url against. Spelled `composio://` here, this read answered an empty map on any other + * broker — every `serverId` undefined, every connection dropped, and the connected-accounts + * page drawn empty for somebody holding accounts. Fail-closed and visible, and total. + */ + const canonical = (toolkit: string) => + `${BROKERED_PROFILE[brokerProvider].scheme}${toolkit}`; const named = await brokeredAppRowsAt( - connections.map((row) => `composio://${row.toolkit}`), + connections.map((row) => canonical(row.toolkit)), ); const rows = connections .flatMap((row) => { - const serverId = named.get(`composio://${row.toolkit}`)?.id; + const serverId = named.get(canonical(row.toolkit))?.id; return serverId === undefined ? [] : [{ ...row, serverId }]; }) // By server id, as the join's own `order by` was, so this read and `connectionsFor` hand the @@ -487,7 +512,7 @@ export function connectionMethods(context: StoreContext) { .from(mcpServers) .where(eq(mcpServers.id, serverId)) .limit(1); - const toolkit = server ? toolkitOf(server.url) : null; + const toolkit = server ? toolkitOfFor(brokerProvider, server.url) : null; if (!toolkit) return null; const [row] = await database @@ -504,7 +529,7 @@ export function connectionMethods(context: StoreContext) { .leftJoin(users, eq(users.id, brokeredConnections.connectedBy)) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), eq(brokeredConnections.holder, "deployment"), ), @@ -571,7 +596,7 @@ export function connectionMethods(context: StoreContext) { .orderBy(asc(mcpServers.id)); const answering = await brokeredAppRowsAt( marked - .filter((row) => toolkitOf(row.url) !== null) + .filter((row) => toolkitOfFor(brokerProvider, row.url) !== null) .map((row) => row.url), ); const shared = marked.filter( @@ -580,7 +605,7 @@ export function connectionMethods(context: StoreContext) { return await Promise.all( shared.map(async (server) => { - const toolkit = toolkitOf(server.url); + const toolkit = toolkitOfFor(brokerProvider, server.url); const [connection, kind] = await Promise.all([ this.deploymentConnectionFor(server.id), toolkit ? brokeredAppKind(toolkit) : null, @@ -834,6 +859,11 @@ export function connectionMethods(context: StoreContext) { * AN UPDATE AND NEVER AN INSERT, and `returning` is how "there was a row" is ANSWERED rather * than assumed. An update that matches nothing is not a failure — it is the row having been * deleted while the vendor was being asked — and the caller has a sentence for that. + * + * `vendor_user_id` IS DELIBERATELY NOT IN THIS `set`. This branch records a CHECK of a + * connection that already exists, never a connection; it may not re-point the row at a + * different vendor identity, because only a connect or a confirm (the upsert below) is + * evidence of a grant held under the identity it names. */ const written = await database .update(brokeredConnections) @@ -843,10 +873,38 @@ export function connectionMethods(context: StoreContext) { return { verifiedAt, wrote: written.length > 0 }; } + /* + * THE VENDOR IDENTITY THIS WRITE'S EVIDENCE IS ABOUT, WRITTEN ON BOTH BRANCHES OF THE UPSERT. + * + * CRITERION. After this upsert the row names `input.account.vendorUserId`, whatever it named + * before. + * + * REASON. Every caller of the upsert has just heard from the vendor about the account held + * under THIS id — a consent, or the vendor confirming it holds an account — and + * `connectionTokenFor` sends calls out under the ROW's `vendor_user_id`, never a recomputed + * one. A conflict arm that rewrote `verified`/`verified_at` and left the old id standing + * wrote a verdict about one account onto a row naming another, and every later call landed + * in the account the evidence was not about. The insert always set it; the arms did not. + */ + const connectedAs = input.account.vendorUserId; await database .insert(brokeredConnections) .values({ - provider: "composio", + /* + * THE BROKER THIS ROW WAS ACTUALLY MINTED AT, which is this store's own, because this + * insert only ever runs behind a connection just made at the broker this store holds. + * + * CRITERION. A row inserted here carries the provider whose grant it stands for, so the + * reads keyed on `provider` above find exactly the rows this writer wrote. + * + * REASON. The literal `composio` here was the one line that could not fail loudly. An + * Arcade deployment writing it would read its own rows back through a `composio` filter + * and APPEAR TO WORK, while the refusal that is supposed to fire when the other provider + * holds rows never fired — nothing would ever be filed under the other name — and the + * offboarding sweep, keyed the same way, would sweep a slice of the table that was not + * this deployment's. Every symptom of that is silence. + */ + provider: brokerProvider, app: input.toolkit, holder: input.account.holder, userId: @@ -869,7 +927,7 @@ export function connectionMethods(context: StoreContext) { brokeredConnections.userId, ], targetWhere: sql`${brokeredConnections.holder} = 'person'`, - set, + set: { ...set, vendorUserId: connectedAs }, } : { target: [brokeredConnections.provider, brokeredConnections.app], @@ -879,15 +937,560 @@ export function connectionMethods(context: StoreContext) { // which `set` otherwise never said, leaving the row crediting whoever // connected it the first time. Added only when this call names a // `connectedBy` at all, so a re-check or a confirm that passes none does not - // blank out the administrator an earlier connect already recorded. + // blank out the administrator an earlier connect already recorded. That + // condition is the point: do NOT make this unconditional. set: input.connectedBy - ? { ...set, connectedBy: input.connectedBy ?? null } - : set, + ? { + ...set, + vendorUserId: connectedAs, + connectedBy: input.connectedBy, + } + : { ...set, vendorUserId: connectedAs }, }, ); return { verifiedAt, wrote: true }; }, + /** + * The other apps this deployment holds a connection for that share THIS app's connection: the + * apps on the same vendor identity AND the same AUTH PROVIDER, never the provider alone. The + * reason is written out on {@link connectionSiblings}; this is that computation over the rows + * this deployment actually holds under the active provider. + * + * THE BROKER THIS STORE WAS BUILT WITH ANSWERS IT, AND NO CALLER SUPPLIES A RESOLVER. The auth + * provider is the vendor's fact and this store keeps no copy of it, so it is asked for — of + * {@link ConnectedAppBroker.authProviderOf}, which both providers implement. A resolver taken as + * a parameter would be a question every caller had to answer identically or see a different + * sibling set; with the store calling its own broker there is ONE answer by construction and + * nothing to keep in step. A deployment with no broker answers `[]` — a presence test on a + * declared value, and correct, because such a deployment holds no brokered rows at all. + * + * THE SCAN IS PRE-FILTERED ON THE ASKING IDENTITY, IN THE `WHERE`. Resolving the auth provider + * is a vendor call per app, on a screen somebody is waiting on; resolving it for every brokered + * row of the whole deployment and only then discarding the rows on other identities is unbounded + * in the one dimension that grows. `vendor_user_id` is a column, so the filter is free — and + * semantically identical, because {@link connectionSiblings} discards exactly those rows. + * + * An app the broker cannot answer for (`null`) is left out rather than guessed at: claiming a + * sibling that is not one is the failure D26 exists to prevent. If the asking app itself is + * unanswered the list is empty. + * + * `unanswered` IS HOW MANY OTHER APPS ON THE ASKING IDENTITY WERE LEFT OUT FOR THAT REASON — an + * integer, never names; see {@link resolveSiblings} for why it is surfaced. + * + * IT ANSWERS A FACT AND ACTS ON NOTHING: no revocation, no deletion, no audit write. Returns the + * sibling toolkits, sorted, never including `toolkit`, and the unanswered count. + */ + async siblingApps(input: { + toolkit: string; + account: AccountRef; + }): Promise<{ siblings: string[]; unanswered: number }> { + return resolveSiblings(input); + }, + + /** + * Start an Arcade authorization attempt for one person: take the key if it is free, expired or + * completed, and refuse if a LIVE attempt holds it. ONE STATEMENT. + * + * `actorId` IS THE SESSION SUBJECT (`users.id`), NEVER `credentialActorId`. This file reads + * `input.credentialActorId ?? input.actorId`, and on a Team Bot those differ, and neither is the + * person holding the browser. The verifier finds the attempt from the returning browser's + * session, so any other key writes attempts a browser can never match. + * + * ONE STATEMENT BECAUSE A READ-THEN-WRITE LETS TWO REQUESTS BOTH SEE "NO LIVE ATTEMPT" AND BOTH + * INSERT. The conditional `ON CONFLICT ... DO UPDATE ... WHERE` is what makes the claim atomic: + * the second request waits on the first's row and then evaluates the WHERE against it. The + * live row is answered by the same statement, in a branch that only produces rows when the + * claim produced none. It can be null: a rival that committed after this statement took its + * snapshot is live but not yet visible to it, and the caller's 409 then names nothing. + * + * `created_at` is truncated to milliseconds so the `Date` handed back names the stored value + * exactly — a microsecond no `Date` can carry would otherwise sit in the row unreachable. + * {@link recordAuthorizationMint} and {@link discardAuthorizationAttempt} do not RELY on that: + * they truncate the column in the comparison too, so a row written by anything else — the + * column's own `defaultNow()`, say — is still reachable. See {@link attemptNamed}. + */ + async claimAuthorizationAttempt(input: { + actorId: string; + serverId: string; + action: string; + account: AccountRef; + }): Promise< + | { claimed: true; attempt: PendingAuthorization } + | { claimed: false; live: PendingAuthorization | null } + > { + const rows = await database.execute(sql` + WITH claimed AS ( + INSERT INTO pending_authorizations + (actor_id, server_id, action, holder, user_id, vendor_user_id, created_at) + VALUES ( + ${input.actorId}, ${input.serverId}, ${input.action}, ${input.account.holder}, + ${input.account.holder === "person" ? input.account.userId : null}, + ${input.account.vendorUserId}, date_trunc('milliseconds', now()) + ) + ON CONFLICT (actor_id) DO UPDATE SET + server_id = EXCLUDED.server_id, + action = EXCLUDED.action, + holder = EXCLUDED.holder, + user_id = EXCLUDED.user_id, + vendor_user_id = EXCLUDED.vendor_user_id, + auth_id = NULL, + created_at = EXCLUDED.created_at, + completed_at = NULL, + outcome = NULL + WHERE pending_authorizations.created_at < now() - interval '15 minutes' + OR pending_authorizations.completed_at IS NOT NULL + RETURNING * + ) + SELECT true AS claimed, claimed.* FROM claimed + UNION ALL + SELECT false AS claimed, p.* FROM pending_authorizations p + WHERE p.actor_id = ${input.actorId} + AND NOT EXISTS (SELECT 1 FROM claimed) + `); + const row = rows[0] as Record | undefined; + if (!row) return { claimed: false, live: null }; + const attempt = pendingAuthorizationFromRow(row); + return row.claimed === true + ? { claimed: true, attempt } + : { claimed: false, live: attempt }; + }, + + /** + * Write the `ar_...` the vendor minted onto the attempt, matched on `(actor_id, created_at)` + * through {@link attemptNamed}, which is where the precision of that match is argued. + * + * WRITE-ONCE, AND NEVER AFTER SETTLEMENT: the UPDATE also requires `auth_id IS NULL` and + * `completed_at IS NULL`. `auth_id` is what binds the attempt to the authorization the person + * was actually sent to consent to, and both conditions are needed to keep that binding fixed. + * + * `completed_at IS NULL` is {@link closeAuthorizationAttempt}'s rule, for the same reason: a + * settled attempt is a record, not a draft. Without it, a late or duplicated mint rewrote a + * completed attempt's `ar_...`, even to NULL, and the row no longer named the authorization + * the person consented to. + * + * `auth_id IS NULL` is needed because `completed_at IS NULL` alone still lets a duplicated mint + * rebind a LIVE attempt. Once the person has the first id's consent URL, writing a second id + * (or NULL) makes their return leg match nothing. A NULL `auth_id` is never valid after + * settlement: every settling write (`completeAuthorizationAttempt` and the three verdicts + * through `closeAuthorizationAttempt`) matches `auth_id = $1`, which NULL never satisfies. + * So `auth_id IS NULL` alone would already rule out any row the store settled. `completed_at + * IS NULL` is kept anyway, so the guard does not depend on every future writer settling + * through those paths. + * + * The first write is the only one, and it can be NULL: a vendor that answered with no id + * leaves the attempt null, and it expires. Answers whether it wrote. False means the attempt + * was discarded, replaced, already minted or already settled. The route does not act on the + * answer. + */ + async recordAuthorizationMint(input: { + actorId: string; + /** A `Date`, or the ISO string one becomes through JSON; see {@link attemptInstant}. */ + createdAt: Date | string; + /** Null when the vendor answered with no id: the attempt then can never complete. */ + authId: string | null; + }): Promise { + const written = await database + .update(pendingAuthorizations) + .set({ authId: input.authId }) + .where( + and( + attemptNamed(input.actorId, input.createdAt), + isNull(pendingAuthorizations.authId), + isNull(pendingAuthorizations.completedAt), + ), + ) + .returning({ actorId: pendingAuthorizations.actorId }); + return written.length > 0; + }, + + /** + * Delete one attempt, matched on `(actor_id, created_at)` SO IT CAN NEVER DELETE A NEWER + * ATTEMPT BY THE SAME ACTOR. Matching on `auth_id` would be wrong: an attempt whose mint never + * completed has none, and that is exactly the one most worth cancelling. + * + * CONDITIONAL ON `completed_at IS NULL`, THE SAME RULE {@link closeAuthorizationAttempt} AND + * THE COMPLETION HOLD, BECAUSE A SETTLED ATTEMPT IS A RECORD AND NOT A DRAFT. `(actor_id, + * created_at)` does not stop naming a row when the row settles, and every caller of this reads + * the attempt FIRST and deletes it AFTER. Unconditional, a completion committing in that gap + * was erased outright — the one row saying this person's consent went through, with the grant + * live at the vendor, the `brokered_connections` row written and `mcp.account_connected` on + * the trail — and the cancel route then answered `{ cancelled: true }` for a consent that had + * succeeded. The DELETE waits on the completion's row lock and re-evaluates this condition + * against the committed row, so the guard holds under the race and not merely before it. + * + * `false` therefore means one of three things — no row under that key, a row under a different + * `created_at`, or a row that has settled — and all three are "nothing was discarded". A caller + * that must tell the third apart asks {@link settledAuthorizationOutcome}. + */ + async discardAuthorizationAttempt(input: { + actorId: string; + /** A `Date`, or the ISO string one becomes through JSON; see {@link attemptInstant}. */ + createdAt: Date | string; + }): Promise { + const deleted = await database + .delete(pendingAuthorizations) + .where( + and( + attemptNamed(input.actorId, input.createdAt), + isNull(pendingAuthorizations.completedAt), + ), + ) + .returning({ actorId: pendingAuthorizations.actorId }); + return deleted.length > 0; + }, + + /** + * How the attempt named by `(actor_id, created_at)` ended, or null while it has not ended, or + * when no row carries that key. + * + * FOR THE ONE CALLER THAT HAS TO SAY WHY A DISCARD REFUSED: the cancel route, which must not + * answer a person whose consent COMPLETED while they were cancelling it as though nothing had + * happened. Named through {@link attemptNamed} like the discard it explains, so it can only + * report the attempt that discard was aimed at and never a newer one by the same actor. Once + * settled, a row keeps that `created_at` until a new claim takes the key and rewrites it, so a + * reading after the refused DELETE answers for the same attempt the DELETE refused. + */ + async settledAuthorizationOutcome(input: { + actorId: string; + createdAt: Date | string; + }): Promise { + const [row] = await database + .select({ outcome: pendingAuthorizations.outcome }) + .from(pendingAuthorizations) + .where(attemptNamed(input.actorId, input.createdAt)); + return row?.outcome ?? null; + }, + + /** + * Finish one Arcade authorization: consume the attempt, record the connection and file the + * trail, ALL THREE IN ONE TRANSACTION OR NONE OF THEM. + * + * THE ATTEMPT ROW IS THE SERIALIZATION POINT AND THE DATABASE ENFORCES IT. The first statement + * is a conditional `UPDATE ... WHERE actor_id = $1 AND auth_id = $2 AND completed_at IS NULL + * RETURNING`, and it is what makes completion happen exactly once. A read of the attempt + * followed by a write would not: two callbacks can both read the row before either consumes it, + * and both would then write a connection and a second `mcp.account_connected`. The second + * caller's UPDATE instead waits on the first's row lock, re-evaluates `completed_at IS NULL` + * against the committed row, and matches nothing. + * + * AN `auth_id` IN AN AUDIT PAYLOAD ENFORCES NOTHING, which is the shape this replaces. + * {@link auditEvents.id} is `uuid().defaultRandom()`, so the trail has no key a second callback + * could collide with — it would simply hold two rows for one connection, and the connection + * upsert would have run twice on the way there. + * + * ZERO ROWS MEANS STOP, AND THE CALLER IS TOLD NOTHING, which is deliberate. Zero rows is + * either somebody else completing this attempt first or a callback naming an attempt that is + * not this one, and NEITHER is a thing this deployment can tell apart or should act on + * differently: both wrote nothing and both leave the browser looking at an account that is + * connected. So this answers the same for the loser as for the winner, and a replayed callback + * is a no-op rather than an error somebody has to explain. A caller wanting to know what the + * row says reads {@link authorizationAttemptFor}. + * + * THE CONNECTION AND THE AUDIT ROW ARE IN THE SAME TRANSACTION AS THAT UPDATE and not merely + * after it. A failure between them used to leave the attempt consumed and nothing recording + * what it was consumed FOR: the person's account is live at the vendor, this deployment holds + * no `brokered_connections` row — which is the whole of the permission a brokered call is + * decided on — and the attempt can never be completed again. The audit insert is bound to the + * transaction for the same reason, through {@link createAuditStore} over the transaction handle + * rather than the store's own `auditStore`, which holds the pool and would commit on its own. + * + * `scopes` IS WHAT THE VENDOR GRANTED, AND IT IS THE ONLY THING FROM THAT ANSWER THAT IS + * WRITTEN ANYWHERE. Arcade's `auth.status` carries a live provider access token and an + * authorization URL beside it; neither reaches this row, this method's arguments, a log line or + * an error. What the trail gets is the action the attempt was for and the scopes that came + * back, which is exactly what is needed later to explain a tool call refused for want of a + * scope — the question {@link mcpUserCredentials.scope} exists to answer for a `user-oauth` + * grant. `authProvider` is the authorization provider this deployment ASKED for (`google`, say, as + * distinct from the broker, `brokered_connections.provider`). The caller resolves it from the + * app's own row at freshness-revalidation time and passes it in; the attempt row has no + * provider column to carry it. It is never read back out of a vendor reply, and the narrowed + * `status` return of D31 makes that structurally impossible. + */ + async completeAuthorizationAttempt(input: { + actorId: string; + authId: string; + toolkit: string; + account: AccountRef; + action: string; + scopes: string[]; + authProvider: string; + }): Promise { + await database.transaction(async (transaction) => { + /* + * ONE STATEMENT, AND THE FIRST ONE. `completed_at` is truncated to milliseconds for the + * reason `claimAuthorizationAttempt` truncates `created_at`: a `Date` carries only + * milliseconds, and a column this deployment writes at microsecond precision is one no + * later `Date`-valued filter can ever match. Nothing matches on this column today; writing + * it the same way is what keeps that true if something does. + */ + const claimed = await transaction.execute(sql` + UPDATE pending_authorizations + SET completed_at = date_trunc('milliseconds', now()), + outcome = 'completed' + WHERE actor_id = ${input.actorId} + AND auth_id = ${input.authId} + AND completed_at IS NULL + RETURNING actor_id + `); + if (claimed.length === 0) return; + + // Read inside the transaction, under the attempt's lock, so the answer the audit row + // carries is the one this very write acted on rather than one a rival moved underneath it. + const [existing] = await transaction + .select({ app: brokeredConnections.app }) + .from(brokeredConnections) + .where(accountRow(input.toolkit, input.account)); + + const verifiedAt = new Date(); + const set = { + verified: true, + verifiedAt, + // NOTHING WAS SPENT TO EARN THAT FLAG. A consent screen is the vendor's own yes, and no + // action of the app's is ever called against it; see `confirmBrokeredConnection`, which + // writes the same pair for the same reason. + probeAction: null, + updatedAt: verifiedAt, + }; + /* + * WHO CONNECTED THE TEAM ACCOUNT, WRITTEN HERE FOR THE SAME REASON EVERY OTHER WRITER + * WRITES IT. `connectedBy` is only ever a deployment-held row's column — a person's row is + * attributed by `user_id` already — and for a Shared app it is the attribution an + * administrator reads off `deploymentConnectionFor` to decide whether a team connection is + * legitimate and who to ask about it. This writer held `actorId`, the session subject + * (`users.id`) the attempt is keyed on and the id the admin read joins `users` on, and + * recorded none of it: an Arcade-consented Shared app's row was permanently null, and the + * page then said nobody connected it. The null is a real state for rows written before + * this, which is why both readers keep answering `null` rather than inventing a name. + * + * AND THE RE-CONSENT SUPERSEDES, which is `recordBrokeredConnection`'s rule spelled the + * same way: a deployment account is shared, so the administrator who most recently + * completed a consent for it is the one the row credits. Unconditional here, unlike there, + * because this path always holds an actor — there is no caller that passes none. + */ + const connectedBy = + input.account.holder === "deployment" ? input.actorId : null; + await transaction + .insert(brokeredConnections) + .values({ + // The broker this row was actually minted at, which is this store's own — never a + // literal, for the reason `recordBrokeredConnection` sets out at length. + provider: brokerProvider, + app: input.toolkit, + holder: input.account.holder, + userId: + input.account.holder === "person" ? input.account.userId : null, + vendorUserId: input.account.vendorUserId, + connectedBy, + ...set, + }) + .onConflictDoUpdate( + input.account.holder === "person" + ? { + target: [ + brokeredConnections.provider, + brokeredConnections.app, + brokeredConnections.userId, + ], + targetWhere: sql`${brokeredConnections.holder} = 'person'`, + // The identity the grant was just made under, for `recordBrokeredConnection`'s + // reason: calls go out under the row's id, so a re-consent that left the old one + // standing sent them to an account this consent says nothing about. + set: { ...set, vendorUserId: input.account.vendorUserId }, + } + : { + target: [ + brokeredConnections.provider, + brokeredConnections.app, + ], + targetWhere: sql`${brokeredConnections.holder} = 'deployment'`, + set: { + ...set, + vendorUserId: input.account.vendorUserId, + connectedBy, + }, + }, + ); + + await recordAuditEvent( + createAuditStore(transaction as unknown as Database), + { + eventType: "mcp.account_connected", + targetType: "mcp_server", + // The app, the id every other writer of this event files a brokered connection under, + // so one query answers what happened to one person's access to one app however it + // began and however it ended. + targetId: input.toolkit, + payload: { + actor: input.actorId, + server: input.toolkit, + /* + * WHAT THE VENDOR GRANTED, WHICH A BROKERED ROW HAS NEVER BEEN ABLE TO SAY BEFORE. + * Both Composio writers carry `""` because Composio holds the grant and never tells + * this deployment what it covers; Arcade does tell us, on the key all four writers + * already agree on, so the same query reads both. + */ + scope: input.scopes.join(" "), + // The action the attempt was for: what this grant was obtained in order to call. + action: input.action, + authProvider: input.authProvider, + reconnected: Boolean(existing), + holder: input.account.holder, + }, + }, + ); + }); + }, + + /** + * Close an attempt as `failed` or `unreconciled`, matched on `(actor_id, auth_id)`. + * + * ON THE PAIR AND NOT ON `created_at`, unlike {@link recordAuthorizationMint} and + * {@link discardAuthorizationAttempt}, because the caller here is the returning callback and + * all it holds is the `ar_...`. An attempt with no `auth_id` matches nothing, which is correct: + * no callback can be about an attempt that never minted one. + * + * CONDITIONAL ON `completed_at IS NULL`, exactly as the completion above is: an attempt that + * has settled — completed, failed, unreconciled or withdrawn — is not reopened by a later + * callback, and `false` says so. See {@link closeAuthorizationAttempt} for what the asymmetry + * used to be and what it cost. + */ + async failAuthorizationAttempt(input: { + actorId: string; + authId: string; + }): Promise { + return await closeAuthorizationAttempt(input, "failed"); + }, + + /** + * Mark an attempt whose outcome this deployment could not settle — the vendor answered, and + * what it answered could not be reconciled with the attempt. Kept apart from `failed` so an + * operator can tell "the person declined" from "we do not know what happened". + */ + async markAuthorizationUnreconciled(input: { + actorId: string; + authId: string; + }): Promise { + return await closeAuthorizationAttempt(input, "unreconciled"); + }, + + /** + * Mark an attempt whose consent completed at the vendor and which this deployment then refused + * and ended by withdrawing the grant — neither a decline nor an unknown. An operator seeing a + * run of these is seeing apps whose mode is being switched inside a consent window, which is a + * different problem from people declining. + */ + async markAuthorizationWithdrawn(input: { + actorId: string; + authId: string; + }): Promise { + return await closeAuthorizationAttempt(input, "withdrawn"); + }, + + /** + * The live attempt for a session subject (`users.id`, never `credentialActorId`), or null when + * there is none, it is past the 15-minute TTL, or it has completed. + */ + /** + * Whether this attempt's completion COMMITTED — asked of the row the transaction serializes on. + * + * FOR ONE CALLER AND ONE QUESTION: `/arcade/verify`'s completion rejected, and "the call threw" + * is not "the work rolled back". A connection lost on the way back from a COMMIT the server had + * already applied rejects at the client over a grant that is written down, and the verifier + * telling that person `not-recorded` sends them to mint a second consent at the vendor against + * a connection they already hold. The attempt row is the one place that can say which happened: + * {@link createPluginStore}'s `completeAuthorizationAttempt` stamps `outcome = 'completed'` in + * the first statement of the transaction, so the stamp exists exactly when the transaction did. + * + * NOT {@link authorizationAttemptFor}, which cannot answer this. It filters on `completed_at IS + * NULL` AND on the fifteen-minute TTL, so it answers `null` for a completed attempt and for an + * expired one alike — and a verifier that read "no live attempt" as "it committed" would + * report a grant that is not there for any completion that ran long enough to age out. + * + * MATCHED ON `(actor_id, auth_id)` like the closers above and never on `created_at`, because + * what the caller holds at this point is the `ar_...`. A rival callback having completed the + * same attempt first answers `true` here, which is correct: the grant is recorded either way, + * and this deployment has never been able or willing to tell the winner from the loser. + */ + async authorizationAttemptCompleted(input: { + actorId: string; + authId: string; + }): Promise { + const [row] = await database + .select({ actorId: pendingAuthorizations.actorId }) + .from(pendingAuthorizations) + .where( + and( + eq(pendingAuthorizations.actorId, input.actorId), + eq(pendingAuthorizations.authId, input.authId), + eq(pendingAuthorizations.outcome, "completed"), + ), + ); + return row !== undefined; + }, + + async authorizationAttemptFor( + actorId: string, + ): Promise { + const [row] = await database + .select() + .from(pendingAuthorizations) + .where( + and( + eq(pendingAuthorizations.actorId, actorId), + isNull(pendingAuthorizations.completedAt), + sql`${pendingAuthorizations.createdAt} >= now() - interval '15 minutes'`, + ), + ); + return row ?? null; + }, + + /** + * File `mcp.authorization_cancelled` for an attempt its own person has just retracted. + * + * CALLED ONLY AFTER {@link discardAuthorizationAttempt} ANSWERED TRUE, by the cancel route and by + * nothing else. The same deletion also tidies up after a refused or unrecorded mint, and those + * are this deployment cleaning up after itself, not a person withdrawing consent — so the row is + * filed here, for the one caller whose act it describes, and not inside the deletion. + * + * TARGETED AT THE APP'S SLUG, read off the attempt's server row with {@link toolkitOfFor}, which + * is the id `mcp.account_connected` and `mcp.account_disconnected` file a brokered app under, so + * one query on the target answers every ending of one app's consent. An attempt OUTLIVES what + * its row's url says — an administrator can re-point the row while a consent is in flight, and + * the cancel route deliberately still lets the person retract it — and once the url names no + * app the row's own id is the only name left for what was cancelled, so that is what the row is + * filed under; `serverId` is in the payload either way. + * + * WHO DID IT GOES IN THE PAYLOAD, as on every writer here, never in `actorUserId`. And the + * payload carries NEITHER the consent url NOR the attempt's `auth_id`: the attempt row never held + * the url, and the vendor's handle is not needed to say what a person withdrew. `minted` is + * whether a flow had been started at the vendor at all. + */ + async recordAuthorizationCancelled(input: { + actorId: string; + attempt: PendingAuthorization; + }): Promise { + const [row] = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.id, input.attempt.serverId)) + .limit(1); + const toolkit = row ? toolkitOfFor(brokerProvider, row.url) : null; + await recordAuditEvent(auditStore, { + eventType: "mcp.authorization_cancelled", + targetType: "mcp_server", + targetId: toolkit ?? input.attempt.serverId, + payload: { + actor: input.actorId, + server: toolkit, + serverId: input.attempt.serverId, + action: input.attempt.action, + holder: input.attempt.holder, + minted: input.attempt.authId !== null, + }, + }); + }, + /** * Spend one call on this person's key, and say what the vendor made of it. * @@ -1012,9 +1615,42 @@ export function connectionMethods(context: StoreContext) { * Composio transport strips before anything reaches the vendor and asserts that it did, so * what Composio is handed is the action and an empty argument object. */ + /* + * AND ON ANY OTHER BROKER THIS REFUSES RATHER THAN DIALS, because `composioAskAction` is + * Composio's own `askAction` and no counterpart has been written for a second broker. + * + * CRITERION. A deployment whose broker is not Composio gets a stated refusal here, and no + * request leaves this process. + * + * REASON. The url this used to compose was `composio://` whatever the broker was, so + * an Arcade deployment's check was sent to Composio with a Composio-shaped url. On a + * deployment holding no Composio key that fails at a vendor it has no business talking to and + * the person is told their connection could not be checked — an outage reported where there + * was none. Where a Composio key IS present it is worse: the slug is a plain app name, so the + * call lands on an UNRELATED app of the same name at the wrong vendor and succeeds, and the + * row records a verified check of an account nobody holds. + * + * `unreachable` IS THE HONEST OUTCOME, not `nothing`. Nothing was learned about the key and + * nothing is recorded about it — `probe` is null, so {@link _ProbeOutcomeLeaves} leaves the + * row untouched and `probe_action` unwritten — and `attempted` is what WOULD have been spent, + * which is the fact about this deployment's intent that field exists to carry. The sentence + * names the state rather than blaming the connection, because the gap is ours. + * + * TODO: delete this once a probe is designed for the second broker. Until then a refusal that + * says so is the only answer that is not a claim about somebody's account. + */ + if (brokerProvider !== "composio") { + return { + outcome: "unreachable", + probe: null, + attempted: candidate.name, + sentence: `This deployment brokers its apps through ${BROKERED_PROFILE[brokerProvider].vendor}, and the only connection check this deployment knows how to make is Composio's. Nothing was asked about this connection, so nothing is recorded about it.`, + }; + } + const { result, answered } = await composioAskAction( { - url: `composio://${input.toolkit}`, + url: `${BROKERED_PROFILE.composio.scheme}${input.toolkit}`, actorId: input.account.vendorUserId, // Spread rather than passed as `undefined`, so "any account of theirs" reaches the wire // as a body with no such key. See {@link ComposioActions.execute}. @@ -1139,7 +1775,7 @@ export function connectionMethods(context: StoreContext) { }): Promise<{ connected: boolean }> { // Before anything, and for the reason `addBrokeredApp` says it first too: a deployment with // no key has no broker to have connected anybody at, so there is nothing here to ask. - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); const connected = await broker.isConnected({ account: input.account, @@ -1453,7 +2089,7 @@ export function connectionMethods(context: StoreContext) { }): Promise<{ connected: true; verified: boolean; probe: string | null }> { // First, and for `confirmBrokeredConnection`'s reason: a deployment with no key has nobody to // connect anybody at, and the refusal must happen before the values are touched at all. - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); // Keyed on the url and on the one row that answers for it — see `brokeredAppScheme`. It is // the same reasoning the connection gate in `connectionTokenFor` is keyed on, and for the @@ -1855,7 +2491,7 @@ export function connectionMethods(context: StoreContext) { verifiedAt: string | null; probe: string | null; }> { - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); // Keyed on the url and on the one row that answers for it — see `brokeredAppKind`. It is the // read `connectBrokeredWithFields`, `confirmBrokeredConnection` and `disconnectBrokered` all @@ -1942,7 +2578,7 @@ export function connectionMethods(context: StoreContext) { */ if (probed.outcome === "unreachable") { throw new PluginRefusedError( - `${input.toolkit} could not be checked just now: ${probed.sentence} Nothing here changed — your connection is still recorded exactly as the last check left it — so pressing Re-check again when Composio is answering is the whole of the retry.`, + `${input.toolkit} could not be checked just now: ${probed.sentence} Nothing here changed — your connection is still recorded exactly as the last check left it — so pressing Re-check again when ${BROKERED_PROFILE[brokerProvider].vendor} is answering is the whole of the retry.`, null, ); } @@ -2162,7 +2798,7 @@ export function connectionMethods(context: StoreContext) { */ reason: "self" | "person_removed" | "admin" | "mode_switched"; }): Promise<{ vendorRevocationRequested: boolean }> { - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) throw new BrokerUnconfiguredError(brokerProvider, "person"); /* * Keyed on the url and on the one row that answers for it — see {@link brokeredAppKind}. @@ -2179,7 +2815,10 @@ export function connectionMethods(context: StoreContext) { * and an app it cannot say holds a key is one whose withdrawal it has to report as asked. */ /** - * WHAT THIS DISCONNECT CLAIMS ON THE TRAIL FOR EACH KIND OF APP. + * WHAT THIS DISCONNECT CLAIMS ON THE TRAIL FOR THE ASKED APP, BY KIND. What it does about a + * SIBLING of each kind is a different decision and is written down where it is made — see + * rule 1 on {@link clearEndedSiblings}, which for a key DELETES NOTHING AND CLAIMS NOTHING + * rather than claiming no revocation about a row it removed. * * Type-only and erased; see {@link Decides}. `=== "key"` collapses three answers into two, * and the collapse is deliberate here rather than accidental: the field below is a claim that @@ -2196,20 +2835,60 @@ export function connectionMethods(context: StoreContext) { unreadable: "reports it as asked for too, which is the claim that cannot be too weak"; } >; - const fieldScheme = (await brokeredAppKind(input.toolkit)) === "key"; + /* + * THE APPS THIS REVOCATION ENDS BESIDES THE ONE ASKED ABOUT, read BEFORE anything is revoked. + * Where a connection is keyed `(identity, auth provider)` — Arcade — withdrawing one app's + * connection ends every app that shares it, so a local row left for one of them claims an + * account the vendor no longer holds, which is the other way to lie. Where each app is its + * own auth provider — Composio — this is always empty and nothing below differs from a + * disconnect of one. The question is {@link resolveSiblings}' and none of it is derived here. + */ + const { siblings, unanswered } = await resolveSiblings({ + toolkit: input.toolkit, + account: input.account, + }); + + /* + * WHETHER THE ASKED APP IS ONE THIS DEPLOYMENT HOLDS A KEY FOR, which decides WHAT THE TRAIL + * CLAIMS about it and nothing else. Its row goes either way: the person pressed Disconnect on + * THIS app, the row is the whole of what Disconnect works off, and leaving it would be + * refusing the act. The siblings are the opposite question — nobody asked about them — and + * {@link clearEndedSiblings} answers it, rule 1 included. + * + * This was one `keyApps` set spanning the asked app and its siblings, and the set only ever + * weakened the trail claim: a KEY sibling's row was deleted anyway, by the single OAuth + * revoke that never ended it, leaving a live account at the vendor with no local row to + * reach it. + */ + const askedIsKey = (await brokeredAppKind(input.toolkit)) === "key"; // Whether there was an account to end at all, which is what decides if anybody was // disconnected. Named apart from the field below because for a key the two differ: something // ended, and nothing was asked of the provider. + // + // REVOKE BEFORE ANY LOCAL DELETE, and ONE ask: it ends the siblings with it. It THROWS on a + // partial ask, which is the partial failure — the throw leaves this method before a single + // row is deleted or written, every app's row standing, so pressing Disconnect again is the + // recovery. const ended = await broker.revoke({ account: input.account, toolkit: input.toolkit, }); - const vendorRevocationRequested = fieldScheme ? false : ended; + const vendorRevocationRequested = askedIsKey ? false : ended; - // `returning` because whether a row was here is half of what decides if anybody was - // disconnected, and a delete that answered nothing would leave the two cases indistinguishable. + /* + * THE ASKED APP FIRST, and on its own terms — this is the act, not a cascade of it. + * + * `deleted || ended` RATHER THAN RULE 3, deliberately and only here: a disconnect of an app + * whose row this deployment never held is still a withdrawal the vendor made, and the trail + * has to carry it. A SIBLING has no such act of its own to record, which is why + * {@link clearEndedSiblings} files nothing without a row. + * + * `returning` because whether a row was here is half of what decides if anybody was + * disconnected, and a delete that answered nothing would leave the two cases + * indistinguishable. + */ const [deleted] = await database .delete(brokeredConnections) .where(accountRow(input.toolkit, input.account)) @@ -2225,21 +2904,153 @@ export function connectionMethods(context: StoreContext) { server: input.toolkit, // Whose account this was, which is not always who ended it: an administrator // offboarding somebody and a person disconnecting themselves write the same shape of - // row, and only these two fields tell them apart. The deployment's own account has no - // person behind it, so it is named as the deployment rather than as whoever pressed. + // row, and only these two fields tell them apart. The deployment's own account has + // no person behind it, so it is named as the deployment rather than as whoever + // pressed. owner: input.account.holder === "person" ? input.account.userId : "deployment", reason: input.reason, vendorRevocationRequested, + /* + * HOW MANY apps on this identity could not be asked about and so were left out of + * the set below, never their names. An integer, so an operator can see that "we did + * not know" happened; see {@link resolveSiblings}. + */ + unansweredSiblings: unanswered, }, }); } + // Then the apps that went with it, each naming the disconnect that ended it, by the three + // rules that are written down once; see {@link clearEndedSiblings}. + await clearEndedSiblings({ + siblings, + account: input.account, + endedWith: input.toolkit, + revoked: ended, + unanswered, + by: input.by, + reason: input.reason, + }); + return { vendorRevocationRequested }; }, + /** + * THE LOCAL SIDE OF A WITHDRAWAL SOMEBODY ELSE ALREADY MADE AT THE VENDOR: the rows this + * deployment holds for an account a revoke has just ended, and the trail that says why they + * went. + * + * WHO THIS IS FOR. The verifier refuses a consent whose context moved on and withdraws the + * RETIRED identity's app at the vendor — a revoke it issues itself, outside this store, + * because the decision is the route's and the attempt row is what records it. On Arcade that + * one revoke ends the whole `(identity, auth provider)` connection and so every app sharing + * it, and the rows here for those apps would otherwise be left standing: this deployment's own + * records claiming live connections for apps the vendor has already disconnected. A person + * then sees apps listed as connected that fail on first use, and nothing tells them otherwise. + * This is the write that stops that, and it is a STORE method rather than route code for the + * reason every other row write is: `brokered_connections` and the trail are this file's. + * + * IT ASKS NOTHING OF ANY VENDOR AND DECIDES NOTHING BUT WHAT A LOCAL ROW SAYS. The caller + * resolved the siblings — of + * {@link siblingApps}, BEFORE its revoke went out, which is the ordering {@link + * disconnectBrokered} and {@link removeServer} both keep — and the caller watched its revoke + * ANSWER TRUE. Called on anything less, it would be claiming an account gone on the strength + * of an answer nobody got; that rule is {@link removeServer}'s and this one is its other half. + * + * ONLY A ROW THAT WAS ACTUALLY HERE GETS A TRAIL ROW, which is again {@link removeServer}'s + * rule. The act itself is already recorded — on the attempt row, as `withdrawn` — so these + * rows exist to explain why an app's connection VANISHED from this deployment's records, and + * an app that had no row here had nothing to explain. + * + * THAT RULE AND THE TWO BESIDE IT NOW LIVE IN {@link clearEndedSiblings}, which this method + * hands its siblings to and which {@link disconnectBrokered} and {@link removeServer} hand + * their own to. They were spelled three times and the spellings disagreed about every one of + * them. + * + * `reason` IS ONE WORD AND IT IS THIS ONE. "The consent's context moved on" is not any of the + * four {@link disconnectBrokered} takes — nobody disconnected anything, nobody was offboarded, + * and a mode switch is only one of the several ways the context can move — so the trail gets + * its own word rather than the nearest-looking lie. Fixed in the type so there is no second + * caller spelling it differently. + */ + async clearWithdrawnAccount(input: { + toolkit: string; + account: AccountRef; + /** The apps the caller's revoke ended besides `toolkit`; {@link siblingApps} answers it. */ + siblings: string[]; + /** + * How many apps on this identity could not be asked about, an integer and never names, which + * qualifies the `endedWith` claim and goes beside it and nowhere else; see + * {@link resolveSiblings}. + */ + unanswered: number; + by: string; + reason: "context_changed"; + }): Promise<{ cleared: string[] }> { + const cleared: string[] = []; + /* + * THE ASKED APP FIRST, then the apps that went with it, so the trail reads in the order the + * withdrawal happened. The asked app's own row names no other app beside it — it was ended + * by its own ask — and a sibling's names the app that asked, which is the only thing that + * tells a reader why a connection they never touched is gone. + * + * THE ASKED APP TAKES THE SAME THREE RULES, which is this method's whole difference from + * {@link disconnectBrokered}'s asked app and is right for the same reason the rules are: + * nobody here pressed anything about this app either. A caller's revoke ended an OAuth + * connection for a RETIRED identity, so an app this deployment holds a key for was not in + * it (rule 1); the caller watched that revoke answer true, which is the contract above and + * is rule 2 discharged at the door; and a row that was not here had nothing to explain + * (rule 3). The act itself is already recorded on the attempt row, as `withdrawn`. + */ + if ((await brokeredAppKind(input.toolkit)) !== "key") { + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(input.toolkit, input.account)) + .returning({ app: brokeredConnections.app }); + if (deleted) { + cleared.push(input.toolkit); + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: input.toolkit, + payload: { + actor: input.by, + server: input.toolkit, + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + /* + * TRUE FOR EVERY ROW HERE, AND IT IS NOT A GUESS: this method is reached only from a + * revoke that answered true, and on Arcade that ask ended the siblings with the app + * it named. `vendorRevocationRequested: false` means "no vendor was asked", which is + * the one thing none of these rows may say. + */ + vendorRevocationRequested: true, + }, + }); + } + } + + cleared.push( + ...(await clearEndedSiblings({ + siblings: input.siblings, + account: input.account, + endedWith: input.toolkit, + // The contract above: the caller watched its revoke answer true before calling this. + revoked: true, + unanswered: input.unanswered, + by: input.by, + reason: input.reason, + })), + ); + return { cleared }; + }, + /** * Retire every connector credential belonging to one person. * @@ -2367,6 +3178,16 @@ export function connectionMethods(context: StoreContext) { * retirements of the same person revoke in the same order and write their rows in the same * order. */ + /* + * NO PROVIDER PREDICATE HERE, DELIBERATELY, AND THAT IS NOT AN OVERSIGHT. The sweep below + * revokes through the ACTIVE broker, so a row filed under another provider would be a + * revocation requested at the wrong vendor, which would not recognise the connection, and an + * audit row claiming `vendorRevocationRequested` about access that was never removed. What + * keeps that unreachable is the startup activation refusal (`assertActivatable`, called from + * `server/src/index.ts`): a deployment holding both providers' rows cannot boot. The provider + * sunset flow relaxes that refusal, and whoever builds it must add the `brokerProvider` + * predicate here. + */ const brokered = await database .select({ toolkit: brokeredConnections.app }) .from(brokeredConnections) @@ -2378,6 +3199,52 @@ export function connectionMethods(context: StoreContext) { ) .orderBy(asc(brokeredConnections.app)); + /* + * THE APPS EACH OF THIS PERSON'S REVOCATIONS ENDS BESIDES THE ONE ASKED ABOUT, read BEFORE a + * single revoke goes out and through {@link resolveSiblings}, the one place that answers it. + * + * WHY THIS LOOP NEEDS IT AT ALL, when it already asks about every app the person holds. + * Where a connection is keyed `(identity, auth provider)` — Arcade — the FIRST app's revoke + * ends the connection and every app sharing it, so the ask for a sibling that follows finds + * nothing left and answers `false`. Passed through, that is `vendorRevocationRequested: + * false` on the trail for a grant this deployment asked for and got, which is the one + * reading of that field that must never be written: it says "no vendor was asked" about an + * account that was withdrawn, to whoever later checks that an offboarding completed. The + * order the rows come back in decides which app gets the truthful row and which gets the + * false one, which is to say nothing decides it. + * + * Where each app is its own auth provider — Composio — every set here is empty by + * construction, each ask stands alone, and nothing below differs from what this method + * always did. + * + * RESOLVED AGAINST THE PERSON'S OWN IDENTITY, the same `account` the revokes below are asked + * with. MEMOIZED ACROSS THE WHOLE OFFBOARDING for the reason {@link removeServer} gives: + * `authProviderOf` is a live vendor call on Arcade, and the bound is then the number of + * DISTINCT TOOLKITS this person holds rather than that number squared. Empty with no broker + * configured, where nothing is asked of anybody and the `false` below is the whole truth. + */ + const account: AccountRef = { + holder: "person", + userId, + vendorUserId: userId, + }; + const authProviders = new Map>(); + const endedBesides = new Map< + string, + { siblings: string[]; unanswered: number } + >(); + if (broker) { + for (const connection of brokered) { + endedBesides.set( + connection.toolkit, + await resolveSiblings( + { toolkit: connection.toolkit, account }, + authProviders, + ), + ); + } + } + /* * What the broker was actually asked for each app, kept so the trail below records the answer * rather than the call. False where there is no broker at all: a deployment whose key has @@ -2408,10 +3275,13 @@ export function connectionMethods(context: StoreContext) { /* * THE PERSON'S OWN ACCOUNT, NAMED AS ONE. Offboarding somebody ends what they * held and never the deployment's shared account, whoever connected it — so the - * holder is fixed here rather than read off anything, and the vendor id is the - * person's own, which is what a personal account is filed under at Composio. + * holder is fixed above rather than read off anything, and the vendor id is the + * person's own, which is what a personal account is filed under at Composio. The + * SAME value the siblings were resolved against, because an answer about one + * identity describing a revocation on another is the whole of what that set is + * for. */ - account: { holder: "person", userId, vendorUserId: userId }, + account, toolkit: connection.toolkit, }) : false, @@ -2451,8 +3321,39 @@ export function connectionMethods(context: StoreContext) { ); } + /* + * WHICH OF THIS PERSON'S APPS WERE ENDED BY ANOTHER APP'S REVOCATION, and by which one. + * + * Only from a revoke that ANSWERED TRUE: `false` means nothing was found to withdraw or the + * auth provider could not be resolved, and the vendor was not shown to have ended anything, + * so nothing is claimed on the strength of it — the rule {@link removeServer} files its + * siblings by. The asked app is skipped in favour of its own answer, and the first ender + * wins, which is deterministic because the rows are walked in app order. + * + * APPS THIS PERSON HOLDS ARE ALL THIS CAN SPEAK FOR. A sibling resolved on the same identity + * but held by the deployment, or an app whose own revoke threw and so has no row here, is + * left alone: this map only corrects what the loop below is already about to write. + */ + const endedWith = new Map(); + for (const entry of withdrawn) { + if (!entry.requested) continue; + for (const app of endedBesides.get(entry.toolkit)?.siblings ?? []) { + if (app === entry.toolkit) continue; + if (!endedWith.has(app)) endedWith.set(app, entry.toolkit); + } + } + for (const connection of withdrawn) { retired += 1; + /* + * The app whose revocation ended this one, where this one's own ask came back empty + * because that revocation had already taken it. Absent for an app that was ended by its + * own ask, which needs no other grant named beside it. + */ + const endedBy = connection.requested + ? undefined + : endedWith.get(connection.toolkit); + const resolved = endedBesides.get(connection.toolkit); await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", @@ -2473,8 +3374,27 @@ export function connectionMethods(context: StoreContext) { * withdrawal asked for, or there was nothing to ask about, or there was no broker to * ask. The value of the field is exactly that a reader can tell those apart, so a * constant here would be worse than none. + * + * OR WHAT WAS ASKED ABOUT THE CONNECTION THIS APP SHARES, which is the same claim + * made about the same grant. On Arcade the ask that ended this app may have been + * spelled with a sibling's name, and `revoke`'s `false` here then means "already + * gone", not "nobody asked" — `endedWith` beside it names the app that asked. + */ + vendorRevocationRequested: connection.requested || Boolean(endedBy), + /* + * AND WHERE A SIBLING RELATIONSHIP IS BEING CLAIMED, HOW MANY apps on this identity + * could not be asked about and so could not be ruled siblings either way — an + * integer, never their names; see {@link resolveSiblings}. Beside `endedWith` and + * nowhere else, which is where {@link removeServer} puts it and for its reason: the + * count qualifies a claim about which apps went together, and a row making no such + * claim would be carrying a number about nothing. */ - vendorRevocationRequested: connection.requested, + ...(endedBy + ? { + endedWith: endedBy, + unansweredSiblings: resolved?.unanswered ?? 0, + } + : {}), }, }); } diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index 4d001db4f..2efe818d5 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -13,13 +13,19 @@ import { mcpServers, mcpTools, mcpUserCredentials, + pendingAuthorizations, pluginGrants, skillTools, } from "../../db/schema"; -import { accessFor, type ServerAccess } from "../access"; +import { + accessFor, + BROKERED_PROFILE, + type BrokerProvider, + type ServerAccess, + toolkitOfFor, +} from "../access"; import { type Decides, type SchemeKind, schemeKind } from "../broker"; import { type CatalogueEntry, catalogueEntry } from "../catalogue"; -import { toolkitOf } from "../composio"; import { registerDynamicClient } from "../oauth"; import { type AccountAnswer, @@ -28,9 +34,12 @@ import { accountFor, } from "../shared-accounts"; import { + type BrokeredDisconnectReason, CatalogueEntryUnknownError, CLIENT_REREGISTRATION_BACKOFF_MS, + type ConnectionFootprint, CustomServerRefusedError, + connectionSiblings, exchangeRefreshTokenOverHttp, INVALID_CLIENT, isUsableClient, @@ -63,6 +72,12 @@ export function createStoreContext(options: PluginStoreOptions) { // No default, unlike the seams above: there is no real implementation in this tree to fall back // to, and a deployment with no Composio key is supposed to have no broker. See `./broker`. const broker = options.broker; + /* + * The broker this store is for: every read of `brokered_connections`, every `provenance` test and + * the connection writer below read THIS rather than each re-deciding. Taken as given, never + * defaulted — see {@link PluginStoreOptions.brokerProvider} for why there is no fallback here. + */ + const brokerProvider: BrokerProvider = options.brokerProvider; /* * One exchange at a time per (server, person). A rotating vendor invalidates the refresh @@ -265,7 +280,9 @@ export function createStoreContext(options: PluginStoreOptions) { * so only a removal racing this read gets here, and it is refused as the missing row is above. */ const toolkit = - dialled.provenance === "composio" ? toolkitOf(dialled.url) : null; + dialled.provenance === brokerProvider + ? toolkitOfFor(brokerProvider, dialled.url) + : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; if (toolkit && !app) return { @@ -323,17 +340,69 @@ export function createStoreContext(options: PluginStoreOptions) { const accountRow = (toolkit: string, account: AccountRef) => account.holder === "person" ? and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), eq(brokeredConnections.holder, "person"), eq(brokeredConnections.userId, account.userId), ) : and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), eq(brokeredConnections.holder, "deployment"), ); + /** + * The one statement behind `failAuthorizationAttempt`, `markAuthorizationUnreconciled` and + * `markAuthorizationWithdrawn`, so the three verdicts cannot drift into three row shapes. + * `completed_at` is truncated to milliseconds for the reason `claimAuthorizationAttempt` + * truncates `created_at`. + * + * CONDITIONAL ON `completed_at IS NULL`, EXACTLY AS THE COMPLETION ABOVE IS, BECAUSE A SETTLED + * ATTEMPT IS A RECORD AND NOT A DRAFT. Without that condition this UPDATE matched on + * `(actor_id, auth_id)` alone, and `(actor_id, auth_id)` does not stop naming a row when the row + * settles: a return leg that completed is still holding the `ar_...` a second leg will match on. + * So a slow or retried FAILURE callback arriving behind a SUCCESSFUL completion rewrote + * `outcome = 'completed'` as `outcome = 'failed'` and re-stamped `completed_at`, and the attempt + * then said the person's authorization failed while the grant was live at the vendor, the + * `brokered_connections` row was written and `mcp.account_connected` was on the trail. The trail + * and the attempt disagreed about what happened, and the attempt was the one that was wrong. + * + * THE OLD COMMENT HERE ARGUED THE ASYMMETRY WAS THE POINT, on the grounds that "a second one + * re-stamping the same verdict grants nothing to anybody". That is true of the case it imagined — + * `failed` over `failed` — and says nothing about the case that actually happens, which is a + * verdict over a DIFFERENT and already-final one. Completion is not the only write that must + * happen once; it is the only one that was treated that way. + * + * FIRST TERMINAL VERDICT WINS, AND A LATE ONE IS REFUSED RATHER THAN APPLIED. `false` now means + * one of three things — no attempt under that actor, an attempt holding a different `auth_id`, + * or an attempt that has already settled — and all three are "this call did not close anything", + * which is the only distinction a caller has ever acted on. Nothing is lost to the person on the + * losing leg: their own request still ends in the refusal redirect it would have ended in anyway. + * What this deployment cannot yet SAY is that two legs of one authorization disagreed; there is + * no audit event type for it and inventing one is wider than this change. Recording the true + * outcome is strictly better than recording the false one, which is what the alternative was. + */ + const closeAuthorizationAttempt = async ( + input: { actorId: string; authId: string }, + outcome: "failed" | "unreconciled" | "withdrawn", + ) => { + const written = await database + .update(pendingAuthorizations) + .set({ + completedAt: sql`date_trunc('milliseconds', now())`, + outcome, + }) + .where( + and( + eq(pendingAuthorizations.actorId, input.actorId), + eq(pendingAuthorizations.authId, input.authId), + isNull(pendingAuthorizations.completedAt), + ), + ) + .returning({ actorId: pendingAuthorizations.actorId }); + return written.length > 0; + }; + /** * The token one call goes out with, and whose it is — decided from `access.credential`, so that * this function and the audit row cannot disagree about whose account a call ran in. @@ -432,7 +501,7 @@ export function createStoreContext(options: PluginStoreOptions) { */ if (!access.toolkit) { throw new PluginInvariantError( - `${row.id} resolves to a brokered credential with no Composio app in its url.`, + `${row.id} resolves to a brokered credential with no ${BROKERED_PROFILE[brokerProvider].vendor} app in its url.`, ); } @@ -520,6 +589,54 @@ export function createStoreContext(options: PluginStoreOptions) { `${row.title} changed how it is shared while this call was being checked, so it was not called. Ask again.`, null, ); + /* + * A ROW IS ONLY REQUIRED WHERE THIS DEPLOYMENT IS THE ONE HOLDING THE GRANT. + * + * CRITERION. Whether a brokered call has to find a `brokered_connections` row first is the + * broker's DECLARED {@link ConnectedAppBroker.grantIsLocal} — never a `provider === "arcade"` + * comparison, never a test of the row's provenance column, never a look at its url. The + * question is "where does the grant live", the field answers exactly that question, and the + * vendor's name is not an answer to it. + * + * REASON. The gate just below and its `none` exemption above once keyed on DIFFERENT things, + * and the exemption failed OPEN: a duplicate row recording `NO_AUTH` at a key app's url + * skipped the per-person gate entirely, and the deployment's own key ran a call for somebody + * who had connected nothing. A vendor's name compared inside a check whose vocabulary is + * about permission is that same shape — two answers to one question with nothing forcing them + * to agree, and a third broker would mean finding every such comparison by hand. A declared + * property is checkable against every adapter, reads the same as the `listNeedsCredential` + * beside it, and a third provider answers it by existing or fails to type-check. + * + * WHY THE ROW IS NOT REQUIRED WHERE THE GRANT IS THE VENDOR'S. Such a broker checks per + * action, on every call, at the vendor. A local row demanding to exist first would be a + * second, weaker copy of that check — and it is the copy that forces a Connect button, which + * forces an arbitrary first consent before anybody knows which action they want. Here the + * call proceeds and the vendor's own typed refusal is the authoritative answer. + * + * ONLY A PRECONDITION IS REMOVED; THE IDENTITY IS UNCHANGED. The id returned is + * `resolved.ref.vendorUserId` — the very value the branch below returns out of the row — + * derived by {@link accountRefFor} from the session's actor and the app's own mode, Personal + * or Shared, exactly as before. The model cannot name an identity, nothing about whose + * account a call runs in changes here, and the Shared case still goes out as the deployment. + * + * AND THE PROPERTY IS ONLY EVER READ ABOUT THAT BROKER'S OWN ROWS, which is not a vendor-name + * comparison sneaking back in — it is the question of WHOSE declaration this is. + * + * CRITERION. A brokered row is governed by the broker it was minted at. Where that is not the + * configured one, no declaration of the configured broker's applies to it, and the local gate + * stands — which refuses, because a row of the other broker's can have no connection row here. + * + * REASON. `access.transport` is the row's provenance (see `accessFor`) and `broker` is whatever + * this deployment is configured with, and the two are not always the same vendor: a deployment + * that has switched broker still holds the other's rows. Read off the active broker alone, the + * waiver crossed over to them — on an Arcade deployment, a left-over Composio app was called + * with no connection row of any kind, exempted by a per-action check at a vendor that has never + * heard of it, on a deployment that cannot connect, check or disconnect it at all. The + * `grantIsLocal` argument is about where THIS broker's grants live; it says nothing about + * somebody else's. + */ + if (broker && !broker.grantIsLocal && access.transport === brokerProvider) + return { vendorUserId: resolved.ref.vendorUserId }; const [connected] = await database .select({ vendorUserId: brokeredConnections.vendorUserId }) .from(brokeredConnections) @@ -1402,7 +1519,7 @@ export function createStoreContext(options: PluginStoreOptions) { if (!existing) return; if (accessFor(existing, null).credential !== "brokered") return; throw new CustomServerRefusedError( - `${serverId} is a Composio app this deployment has enabled, and people may have connected their accounts to it. Remove the app first — which ends those accounts at Composio — and the name is then free.`, + `${serverId} is ${brokerProvider === "arcade" ? "an" : "a"} ${BROKERED_PROFILE[brokerProvider].vendor} app this deployment has enabled, and people may have connected their accounts to it. Remove the app first — which ends those accounts at ${BROKERED_PROFILE[brokerProvider].vendor} — and the name is then free.`, ); } @@ -1439,7 +1556,10 @@ export function createStoreContext(options: PluginStoreOptions) { * with it, so the audience gate decides on and keys its approval by the row that answers rather * than the one that was dialled. Null for every row that is not a brokered app. */ - const toolkit = row.provenance === "composio" ? toolkitOf(row.url) : null; + const toolkit = + row.provenance === brokerProvider + ? toolkitOfFor(brokerProvider, row.url) + : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; const access = accessFor( app @@ -1504,7 +1624,7 @@ export function createStoreContext(options: PluginStoreOptions) { async function brokeredAppRow( toolkit: string, ): Promise { - const url = `composio://${toolkit}`; + const url = `${BROKERED_PROFILE[brokerProvider].scheme}${toolkit}`; return (await brokeredAppRowsAt([url])).get(url) ?? null; } @@ -1572,6 +1692,202 @@ export function createStoreContext(options: PluginStoreOptions) { return schemeKind(await brokeredAppScheme(toolkit)); } + /** + * THE ONE PLACE THAT ASKS THE BROKER FOR AN AUTH PROVIDER, and so the only place that knows how + * many it could not get. {@link connectionSiblings} says what the answers imply; this is the + * question asked of the rows this deployment holds, with its one extra fact: HOW MANY APPS ON + * THE ASKING IDENTITY WENT UNANSWERED. + * + * THE COUNT EXISTS BECAUSE THE RULE IS ASYMMETRIC ON PURPOSE. An app the broker cannot answer for + * is left out rather than guessed at — naming an app that will not be touched trains people to + * dismiss the warning that matters (D26). But under-naming is not free: somebody presses + * Disconnect and loses an app nobody warned them about, and a deployment whose resolver fails + * systematically would do it silently and for ever. So the number is surfaced, an integer and + * never names, for whoever reads the trail. + * + * Counted over the OTHER apps on the asking identity: the asking app's own row is not a sibling + * candidate. If the asking app itself is unanswered, nothing is resolvable and every other app on + * the identity is unanswered with it. + */ + async function resolveSiblings( + input: { + toolkit: string; + account: AccountRef; + }, + /** + * ONE CALL'S MEMO OF `authProviderOf`, PASSED IN BY A CALLER THAT RESOLVES OVER MANY ACCOUNTS. + * + * Resolving an auth provider is a live vendor call with no cache of its own on Arcade + * (`arcade-adapter`'s `authProviderOf` lists the toolkit's tools every time); on Composio it is + * the toolkit name and costs nothing. A caller that walks every PERSON who connected an app — + * {@link removeServer} — would otherwise ask the vendor for the same handful of toolkits once + * per person and again once per app that person holds, which is unbounded in the number of + * people and times out partway through a removal that has already revoked at the vendor. + * + * A toolkit's auth provider cannot change inside one removal, so answering the second ask from + * the first is semantically identical — the same argument the identity pre-filter above makes + * for its `where`. The memo holds the PROMISE, so two resolutions of one toolkit share one + * in-flight call rather than racing into two. + * + * ITS LIFETIME IS ONE CALL AND IT IS THEN DISCARDED. Omitted, every resolution is asked for + * fresh, which is what a single-account caller wants; nothing here is a process-lifetime cache, + * which would be a different design with an invalidation question this one does not have. + */ + memo?: Map>, + ): Promise<{ siblings: string[]; unanswered: number }> { + if (!broker) return { siblings: [], unanswered: 0 }; + const vendor = broker; + const authProviderOf = (toolkit: string): Promise => { + if (!memo) return vendor.authProviderOf(toolkit); + const asked = memo.get(toolkit); + if (asked) return asked; + const asking = vendor.authProviderOf(toolkit); + memo.set(toolkit, asking); + return asking; + }; + const authProvider = await authProviderOf(input.toolkit); + const held = await database + .select({ + app: brokeredConnections.app, + vendorUserId: brokeredConnections.vendorUserId, + }) + .from(brokeredConnections) + .where( + and( + eq(brokeredConnections.provider, brokerProvider), + eq(brokeredConnections.vendorUserId, input.account.vendorUserId), + ), + ); + if (!authProvider) { + const others = new Set( + held.map((row) => row.app).filter((app) => app !== input.toolkit), + ); + return { siblings: [], unanswered: others.size }; + } + const footprints: ConnectionFootprint[] = []; + const unanswered = new Set(); + for (const row of held) { + const rowProvider = await authProviderOf(row.app); + if (rowProvider) footprints.push({ ...row, authProvider: rowProvider }); + else if (row.app !== input.toolkit) unanswered.add(row.app); + } + return { + siblings: connectionSiblings( + { + app: input.toolkit, + vendorUserId: input.account.vendorUserId, + authProvider, + }, + footprints, + ), + unanswered: unanswered.size, + }; + } + + /** + * THE LOCAL ROWS ONE REVOKE'S CASCADE ENDS, AND THE THREE RULES THAT DECIDE WHICH — in ONE place, + * because they were in two and the two disagreed about all three. + * + * WHAT A CASCADE IS. {@link resolveSiblings} answers which other apps share the connection the + * caller's single revoke withdrew; where a connection is keyed `(identity, auth provider)` — + * Arcade — that one ask ended them too, so their rows here would otherwise go on claiming + * accounts the vendor no longer holds. This is the write that ends them, and it is the ONLY + * thing in this file that deletes a row for an app NOBODY ASKED ABOUT. That is why the rules are + * a function rather than a convention: a row deleted here is a grant somebody still holds at the + * vendor and can no longer reach from this deployment, and the cost of getting it wrong is not + * symmetric with the cost of leaving a row standing. + * + * RULE 1 — AN APP THIS DEPLOYMENT HOLDS A KEY FOR IS NOT WHAT THAT REVOKE ENDED, so its row + * stands and the trail says nothing about it. The withdrawal ended an OAuth connection; a key is + * a credential of this deployment's own and no consent revoke at the vendor reaches it. Deleting + * it destroys a credential nothing withdrew and leaves a LIVE account with no local row to reach + * it; recording it claims a vendor ended something it never held — the reading + * {@link disconnectBrokered} writes `vendorRevocationRequested: false` for, there on an act that + * really was about that app. The scheme is read off `mcp_servers`, which this never deletes from. + * + * RULE 2 — ONLY FROM A REVOKE THAT ANSWERED TRUE (`revoked`). `false` means nothing was found to + * withdraw, or the auth provider could not be resolved, and the vendor was not shown to have + * ended ANYTHING; a sibling's row is then left standing rather than claimed gone on the strength + * of an answer nobody got. It is a parameter and not a caller's discipline because a caller's + * discipline is exactly what one of the two callers did not keep. + * + * RULE 3 — ONLY A ROW THAT WAS ACTUALLY HERE GETS A TRAIL ROW (`if (!deleted) continue`). These + * rows exist to explain why an app's connection VANISHED from this deployment's records, and an + * app that had no row here had nothing to explain. A sibling resolved on this identity but held + * by somebody else — {@link resolveSiblings} reads the identity, {@link accountRow} the holder — + * is exactly that case, and it used to get a row saying it had been disconnected. + * + * THE ONLY SPELLING OF THESE RULES. Three acts cascade a revoke onto its siblings — + * {@link disconnectBrokered}, {@link clearWithdrawnAccount} and {@link removeServer} — and all + * three call this. {@link removeServer}'s loop was the oldest spelling and kept only 2 and 3 for + * itself, deleting a KEY sibling's row on a removal; routing it here closed that (round 2, A5a). + * A fourth cascade belongs here too, not beside it. + * + * `vendorRevocationRequested` IS TRUE ON EVERY ROW THIS WRITES AND IS NOT A GUESS: rule 2 says a + * revoke answered true, and that ask ended the siblings with the app it named. `false` means "no + * vendor was asked", which is the one thing none of these rows may say. `endedWith` names the app + * that asked — the only thing that tells a reader why a connection they never touched is gone — + * and `unansweredSiblings` qualifies that claim, beside it and nowhere else. + * + * IT ASKS NOTHING OF ANY VENDOR. The caller revoked, before it called this; see each caller. + * + * Answers the apps it actually cleared, which is what {@link clearWithdrawnAccount} returns and + * what a caller needs in order to say what went. + */ + async function clearEndedSiblings(input: { + /** {@link resolveSiblings}' answer for `endedWith`. Deduplicated here; order is its own. */ + siblings: readonly string[]; + account: AccountRef; + /** The app whose revoke this cascade belongs to. Never cleared here — it has its own row. */ + endedWith: string; + /** What that revoke ANSWERED. Rule 2. */ + revoked: boolean; + /** {@link resolveSiblings}' count, an integer and never names. */ + unanswered: number; + by: string; + /** + * The trail's one word for why, from the closed set the writers of this event use. Wider than + * any one caller's own `reason`, so {@link removeServer} passes `mcp_server_removed` through + * the same parameter the other two pass theirs. + */ + reason: BrokeredDisconnectReason; + }): Promise { + if (!input.revoked) return []; + const cleared: string[] = []; + for (const app of new Set(input.siblings)) { + if (app === input.endedWith) continue; + if ((await brokeredAppKind(app)) === "key") continue; + // `returning` because whether a row was here is the whole of rule 3, and a delete that + // answered nothing would leave the two cases indistinguishable. + const [deleted] = await database + .delete(brokeredConnections) + .where(accountRow(app, input.account)) + .returning({ app: brokeredConnections.app }); + if (!deleted) continue; + cleared.push(app); + await recordAuditEvent(auditStore, { + eventType: "mcp.account_disconnected", + targetType: "mcp_server", + targetId: app, + payload: { + actor: input.by, + server: app, + // Whose account this was, which is not always who ended it; named as the deployment + // where there is no person behind it. + owner: + input.account.holder === "person" + ? input.account.userId + : "deployment", + reason: input.reason, + vendorRevocationRequested: true, + unansweredSiblings: input.unanswered, + endedWith: input.endedWith, + }, + }); + } + return cleared; + } + return { options, database, @@ -1609,6 +1925,10 @@ export function createStoreContext(options: PluginStoreOptions) { brokeredAppRowsAt, brokeredAppScheme, brokeredAppKind, + brokerProvider, + closeAuthorizationAttempt, + resolveSiblings, + clearEndedSiblings, }; } diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts index 69ef78b10..46d781717 100644 --- a/server/src/plugins/plugin-store/model.ts +++ b/server/src/plugins/plugin-store/model.ts @@ -1,3 +1,4 @@ +import { and, eq, sql } from "drizzle-orm"; import type { ApprovalGate } from "../../approvals/types"; import type { AuditStore } from "../../audit"; import type { ActionPolicy } from "../../computer/policy"; @@ -7,8 +8,14 @@ import type { } from "../../credentials"; import type { Database } from "../../db/client"; import { isQueryFailure, withoutStatement } from "../../db/query-failure"; +import { pendingAuthorizations } from "../../db/schema"; import type { CheckPrivateShare } from "../../proactive/private-share"; -import { type ServerAccess, ServerUnresolvableError } from "../access"; +import { + BROKERED_PROFILE, + type BrokerProvider, + type ServerAccess, + ServerUnresolvableError, +} from "../access"; import type { BrokerConnection, ComposioBroker, @@ -1046,6 +1053,23 @@ export const CLIENT_REREGISTRATION_BACKOFF_MS = 5 * 60_000; export const IDENTITY_ACTION = /(_GET_ME|_PROFILE|_CURRENT_USER|_USER_INFO|_WHOAMI)$/; +/** + * Every broker's id namespace, reserved together. See {@link BROKERED_PROFILE} in `./access` for + * what a profile holds and why it lives there; this is only the list this file reserves off it. + */ +export const BROKERED_ID_PREFIXES = Object.values(BROKERED_PROFILE); + +/** + * Every broker's screen name, reserved together, for the reason {@link BROKERED_ID_PREFIXES} is. + * + * THE KEYS, NOT A SECOND LITERAL LIST. `/admin/plugins/composio` is the app directory's own page and + * `/admin/plugins/arcade` is the Arcade one, and a static route is matched ahead of + * `/admin/plugins/$key` on both — so a server sitting at either id would be listed and then open + * somebody else's page instead of its own. Spelled as its own list, this reservation would be the + * one somebody adds a broker to {@link BROKERED_PROFILE} without adding to. + */ +export const BROKERED_SCREEN_NAMES = Object.entries(BROKERED_PROFILE); + /** What a vendor's token endpoint gave back for a refresh token. */ export type AccessToken = { accessToken: string; @@ -1128,6 +1152,34 @@ export type PluginStoreOptions = { * above — invent a broker to get a store. */ broker?: ComposioBroker; + /** + * Which broker this deployment runs, which is the slice of `brokered_connections` this store + * reads and the word its connection writer stamps on every row it mints. + * + * THE ONE PLACE THE VENDOR IS NAMED. Every read of `brokered_connections` below and the insert in + * {@link createPluginStore}'s `recordBrokeredConnection` used to spell `composio` for themselves — + * eleven comparisons that were each correct while there was one broker and each silently wrong on + * the day there were two. They now all read this. A deployment running Arcade whose store was + * still filtering on `composio` would not fail: it would read an empty slice, write rows nobody + * reads back, and leave the "the other provider holds rows" refusal permanently unfired while the + * offboarding sweep swept the wrong table slice. That is the failure this field exists to make + * impossible to reach by accident. + * + * REQUIRED, UNLIKE `broker` ABOVE, BECAUSE FORGETTING IT HAS NO SYMPTOM. An absent `broker` says + * one true thing — this deployment has not configured one — and every path that needs it raises. + * An absent provider would have had to be guessed, and the only guess available was `composio`: + * an Arcade deployment built by forgetting this field would then read and write the COMPOSIO slice + * of `brokered_connections`, hand its own rows back through a composio filter, and APPEAR TO WORK. + * Spec D3's "the other provider holds rows" refusal would never fire, because nothing would ever + * be filed under the other name, and the offboarding sweep would sweep a slice that is not this + * deployment's. Nothing goes red. There is no error to raise later, so the compiler asks now. + * + * SO THERE IS NO DEFAULT ANYWHERE — not here, not in {@link createPluginStore}, not in a test + * helper. A function that cannot reach the provider takes it as a parameter; a second answer to + * "which provider" is a second thing that can be wrong while agreeing with nothing. `activeProvider` + * in `./broker` decides it from configuration at the one deployment boundary that knows. + */ + brokerProvider: BrokerProvider; /** Where the vendor sends people back; needed to (re)register a dynamic client. */ redirectUri?: string; /** @@ -1137,6 +1189,157 @@ export type PluginStoreOptions = { deploymentId?: string; }; +/** One in-flight Arcade authorization attempt, as the table stores it. */ +export type PendingAuthorization = typeof pendingAuthorizations.$inferSelect; + +/** + * WHY A BROKERED ACCOUNT'S ROW WENT, as the trail spells it, and a closed set rather than free + * text for the reason {@link PluginStore.disconnectBrokered} gives: a reader asking the trail + * WHICH of these happened can be answered only if it is the same word every time. + * + * ONE SET ACROSS EVERY WRITER OF `mcp.account_disconnected`, so that `clearEndedSiblings` — which + * writes for all of them — takes the word rather than re-deciding it. Each writer still narrows + * this to the words IT may use; nothing takes the whole union as its own input. + */ +export type BrokeredDisconnectReason = + /** The person disconnected their own account. */ + | "self" + /** The person was removed from the People screen; {@link PluginStore.retireConnectionsFor}. */ + | "person_removed" + /** An administrator disconnected the deployment's account for a Shared app. */ + | "admin" + /** The app was switched between Personal and Shared, retiring the old mode's accounts. */ + | "mode_switched" + /** A consent's context moved on and the verifier withdrew the retired identity's app. */ + | "context_changed" + /** The app itself was removed; {@link PluginStore.removeServer}. */ + | "mcp_server_removed"; + +/** One app's connection, as far as sibling-finding needs to know it. */ +export type ConnectionFootprint = { + /** The toolkit, spelled as the vendor spells it. Compared exactly, never case-folded. */ + app: string; + /** The identity the connection is held under at the vendor. */ + vendorUserId: string; + /** + * The vendor's own AUTH PROVIDER the connection is held under (`google`), not the broker's name. + * + * NAMED `authProvider` AND NEVER `providerId`, because `provider` in this file means the BROKER — + * `brokered_connections.provider` is read a few lines below this one — and that collision has + * already been paid for once in an audit payload. See {@link ConnectedAppBroker.authProviderOf}, + * which is where this value comes from and which is likewise not the toolkit name. + */ + authProvider: string; +}; + +/** + * The apps that share `of`'s connection, which are the apps sharing BOTH its vendor identity AND + * its AUTH PROVIDER. NEVER THE PROVIDER ALONE (spec D26). + * + * REASON. An Arcade connection is keyed `(user_id, provider_id)`, and this deployment holds MANY + * vendor identities: each person's own, PLUS ONE RANDOM IDENTITY PER SHARED APP, minted by + * `mintDeploymentVendorUserId`. So shared Gmail and shared Slack, both using Google, do NOT share + * a connection: their `user_id`s differ. A computation keyed on `provider_id` alone would warn + * about apps that WILL NOT BE TOUCHED, which trains people to dismiss the warning that matters. + * Silently ending two connections when somebody asked to end one is the failure to avoid. + * + * The auth provider is compared case-insensitively, as the adapter's `connectionsFor` does; the app + * name is not, because Arcade toolkit case is never normalised. The answer never contains `of` + * itself, and is sorted so two callers see the same list. This function ACTS ON NOTHING. + */ +export function connectionSiblings( + of: ConnectionFootprint, + among: readonly ConnectionFootprint[], +): string[] { + const provider = of.authProvider.toLowerCase(); + const found = new Set(); + for (const other of among) { + if (other.app === of.app) continue; + if (other.vendorUserId !== of.vendorUserId) continue; + if (other.authProvider.toLowerCase() !== provider) continue; + found.add(other.app); + } + return [...found].sort(); +} + +/** Raw `execute` rows are snake_case and carry timestamps as whatever the driver decoded. */ +export function pendingAuthorizationFromRow( + row: Record, +): PendingAuthorization { + const when = (v: unknown) => + v === null || v === undefined ? null : new Date(v as string | Date); + return { + actorId: row.actor_id as string, + serverId: row.server_id as string, + action: row.action as string, + holder: row.holder as PendingAuthorization["holder"], + userId: (row.user_id as string | null) ?? null, + vendorUserId: row.vendor_user_id as string, + authId: (row.auth_id as string | null) ?? null, + createdAt: when(row.created_at) as Date, + completedAt: when(row.completed_at), + outcome: (row.outcome as PendingAuthorization["outcome"]) ?? null, + }; +} + +/** + * The instant a caller names an authorization attempt by, as a `Date`, or a refusal naming the + * field. + * + * THE DECLARED TYPE IS `Date` AND THE VALUE IS NOT ALWAYS ONE. `PendingAuthorization.createdAt` is + * typed `Date` and is one while it stays in this process, but an attempt that has been through + * JSON — serialised into a job, a cache or a cross-process hop and parsed back — carries an ISO + * STRING under a `Date` annotation. Handed to drizzle, that string reaches + * `PgTimestamp.mapToDriverValue`, which calls `.toISOString()` on it and raises + * `TypeError: value.toISOString is not a function` from inside the driver: a failure that names + * neither the field nor the caller. An ISO string names the same instant perfectly well, so it is + * accepted; anything that names NO instant is refused HERE, where the message can say what was + * wrong, rather than running as a query that silently matches nothing. + */ +function attemptInstant(createdAt: Date | string): Date { + const instant = + createdAt instanceof Date + ? createdAt + : typeof createdAt === "string" + ? new Date(createdAt) + : new Date(Number.NaN); + if (Number.isNaN(instant.getTime())) { + throw new Error( + `An authorization attempt's created_at must be a Date or an ISO timestamp, not ${JSON.stringify(createdAt) ?? String(createdAt)}.`, + ); + } + return instant; +} + +/** + * The condition that names ONE actor's attempt by the `created_at` a caller holds. + * + * MATCHED AT MILLISECOND PRECISION ON BOTH SIDES, BECAUSE THE TWO SIDES DO NOT CARRY THE SAME ONE. + * `created_at` is a `timestamptz`, which PostgreSQL keeps to the MICROSECOND, and the `Date` the + * driver hands back carries MILLISECONDS. A row stored at `…13.407577` is read back as + * `…13.407Z`, and an exact `=` against that `Date` matches NOTHING — so the mint writes no + * `auth_id`, and per {@link pendingAuthorizations}, "an attempt holding null can never complete": + * a silently dead attempt that waits out its fifteen minutes. + * + * {@link createPluginStore}'s `claimAuthorizationAttempt` writes `date_trunc('milliseconds', now())` + * for exactly this reason, but THAT ONLY HOLDS FOR THE ONE WRITER THAT REMEMBERS. The column's + * default is `now()` at full precision, which is what drizzle's inferred insert type invites any + * other writer — a fixture, a backfill, a future route — to take. Truncating the COLUMN in the + * comparison holds whatever wrote the row, which is why the invariant lives here rather than in + * the schema: it needs no migration and no writer's cooperation. + * + * TRUNCATING CAN NEVER MATCH A DIFFERENT ROW: `actor_id` is the table's PRIMARY KEY, so there is at + * most one attempt per actor and `created_at` is the staleness guard on that one row, never a + * selector among several. It still refuses a value naming an OLDER attempt, which is what stops a + * late discard from deleting the replacement that took the key. + */ +export function attemptNamed(actorId: string, createdAt: Date | string) { + return and( + eq(pendingAuthorizations.actorId, actorId), + sql`date_trunc('milliseconds', ${pendingAuthorizations.createdAt}) = ${attemptInstant(createdAt).toISOString()}::timestamptz`, + ); +} + /** * The literal recorded on the row, which is the scheme a later call must keep using. * diff --git a/server/src/plugins/plugin-store/servers.ts b/server/src/plugins/plugin-store/servers.ts index cdb2f4a7a..17f767a40 100644 --- a/server/src/plugins/plugin-store/servers.ts +++ b/server/src/plugins/plugin-store/servers.ts @@ -13,7 +13,7 @@ import { mcpTools, pluginGrants, } from "../../db/schema"; -import { accessFor } from "../access"; +import { BROKERED_PROFILE, brokeredProvenance, toolkitOfFor } from "../access"; import { type BrokerConnection, BrokerUnconfiguredError, @@ -27,12 +27,17 @@ import { resolveServerUrl, serverCredentialKind, } from "../catalogue"; -import { toolkitOf } from "../composio"; import type { ListedTool } from "../mcp"; -import type { AccountMode, AccountRef } from "../shared-accounts"; +import { + type AccountMode, + type AccountRef, + asAccountRef, +} from "../shared-accounts"; import { transportFor } from "../transport"; import type { StoreContext } from "./context"; import { + BROKERED_ID_PREFIXES, + BROKERED_SCREEN_NAMES, CatalogueEntryUnknownError, CustomServerRefusedError, DEPLOYMENT_ACTOR, @@ -63,8 +68,23 @@ export function serverMethods(context: StoreContext) { brokeredAppRow, brokeredAppRowsAt, brokeredAppScheme, + brokerProvider, + resolveSiblings, + clearEndedSiblings, } = context; return { + /** + * The one provider this store's rows belong to, readable by the layer above it. + * + * EXPOSED SO THE ROUTES DO NOT DECIDE IT A SECOND TIME. `routes.ts` used to take the name off + * the broker it was handed and write `?? "composio"` when there was none — which is a second + * answer to the question {@link PluginStoreOptions.brokerProvider} argues must have exactly one. + * The two came apart precisely where it hurt: a deployment with both keys set and + * `OPENBOT_BROKER_PROVIDER` unset is wired with no broker at all, so the routes read every + * `arcade://` row of this store with Composio's parser. Read from here, the parser the routes + * use and the filter this store queries with cannot disagree. + */ + brokerProvider, accountRefFor, /** * Add a server from the catalogue. @@ -226,23 +246,32 @@ export function serverMethods(context: StoreContext) { /* * Nor may it take the name of a screen. `/admin/plugins/composio` is a static route — the app - * directory's own page — and a static route is matched ahead of `/admin/plugins/$key`, so a - * server sitting at this id would be listed and then open somebody else's page instead of its - * own. Brokered servers are `composio-` and no catalogue entry is called this, which - * leaves a hand-typed id as the only way to reach it. + * directory's own page — and `/admin/plugins/arcade` is the Arcade equivalent, and a static + * route is matched ahead of `/admin/plugins/$key`, so a server sitting at either id would be + * listed and then open somebody else's page instead of its own. Brokered servers are + * `-` and no catalogue entry is called either of these, which leaves a + * hand-typed id as the only way to reach them. + * + * BOTH, ON WHICHEVER BROKER THIS DEPLOYMENT RUNS, and derived from + * {@link BROKERED_SCREEN_NAMES} rather than from a literal — the same argument the id-prefix + * loop six lines below makes for its own namespace: the screen is there to be collided with + * whether or not this deployment brokers through it today, and a reservation spelled twice is + * the one that gets added to once. Each refusal still names the id it refused. */ - if (input.id === "composio") { - throw new CustomServerRefusedError( - "composio is the name of this deployment's own Composio screen, so a server added there could never be opened. Choose another.", - ); + for (const [screen, { vendor }] of BROKERED_SCREEN_NAMES) { + if (input.id === screen) { + throw new CustomServerRefusedError( + `${screen} is the name of this deployment's own ${vendor} screen, so a server added there could never be opened. Choose another.`, + ); + } } /* * NOR THE NAMESPACE THE BROKERED ROWS ARE MINTED IN, which is the reservation above one step * further out. * - * CRITERION. No server added by URL may take an id beginning `composio-`, whether or not a row - * is sitting there today. + * CRITERION. No server added by URL may take an id beginning `composio-` or `arcade-`, whether + * or not a row is sitting there today, and on whichever broker this deployment runs. * * REASON. {@link addBrokeredApp} composes its id as `composio-` from the app an * administrator enabled, so that space is already spoken for by a path that writes @@ -261,10 +290,12 @@ export function serverMethods(context: StoreContext) { * and a policy rule are written against, so a row shadowing another path's namespace inherits * rules that were written about something else. */ - if (input.id.startsWith("composio-")) { - throw new CustomServerRefusedError( - `Names beginning composio- belong to the Composio apps this deployment enables, so ${input.id} is not a name a server added by URL can take. Choose another.`, - ); + for (const { idPrefix, vendor } of BROKERED_ID_PREFIXES) { + if (input.id.startsWith(idPrefix)) { + throw new CustomServerRefusedError( + `Names beginning ${idPrefix} belong to the ${vendor} apps this deployment enables, so ${input.id} is not a name a server added by URL can take. Choose another.`, + ); + } } if (!/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(input.id)) { throw new CustomServerRefusedError( @@ -438,10 +469,10 @@ export function serverMethods(context: StoreContext) { * * NO URL IS TAKEN FROM A CALLER, WHICH IS WHY THERE IS NO HOST RULE HERE. `addCustomServer` * guards the address because the address is what an administrator typed and what a credential - * would then be spent at; this one composes `composio://` itself, and a brokered row is - * never dialled at a host at all — the transport reads the app off that url and asks Composio, - * over the deployment's own key. So the only thing left to check about the url is that it says - * what this call meant, and {@link toolkitOf} is what checks it: the slug goes in, the url comes + * would then be spent at; this one composes `://` itself from the store's provider, and a brokered row is + * never dialled at a host at all — the transport reads the app off that url and asks the active + * broker, over the deployment's own key. So the only thing left to check about the url is that it says + * what this call meant, and {@link toolkitOfFor} is what checks it: the slug goes in, the url comes * back out through the very function `accessFor` will read it with, and a slug those two * disagree about is refused rather than stored. A pattern written here instead would be a second * opinion about the shape of an app name, and the reading that decides which app a call runs @@ -480,12 +511,15 @@ export function serverMethods(context: StoreContext) { }): Promise { // Before anything at all. A deployment with no key has no catalogue for this app to have been // chosen from, so there is nothing here to half-do and nothing to say but the setting. - if (!broker) throw new BrokerUnconfiguredError(); + if (!broker) + throw new BrokerUnconfiguredError(brokerProvider, "operator"); - const url = `composio://${input.slug}`; - if (toolkitOf(url) !== input.slug) { + const profile = BROKERED_PROFILE[brokerProvider]; + const url = `${profile.scheme}${input.slug}`; + const rowId = `${profile.idPrefix}${input.slug}`; + if (toolkitOfFor(brokerProvider, url) !== input.slug) { throw new CustomServerRefusedError( - `${input.slug} is not a name a Composio app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, + `${input.slug} is not a name ${brokerProvider === "arcade" ? "an" : "a"} ${profile.vendor} app can have. An app is named in letters, numbers, underscores and hyphens, because that name is read back out of this row's url to decide which app a call is against.`, ); } @@ -546,14 +580,14 @@ export function serverMethods(context: StoreContext) { await database .insert(mcpServers) .values({ - id: `composio-${input.slug}`, + id: rowId, title: input.title, logo: input.logo ?? null, // The broker, whoever publishes the app behind it. `vendor` is what the first-party rule // is checked against, and Composio is who this deployment is actually talking to. - vendor: "Composio", + vendor: profile.vendor, url, - provenance: "composio", + provenance: brokerProvider, // Nothing for the vault to hold. A brokered call runs as the person asking, on their own // connection at the vendor, which is a `composio_connections` row rather than a secret. credentialId: null, @@ -627,8 +661,8 @@ export function serverMethods(context: StoreContext) { * host somebody typed is answering that rule about a server this deployment no longer * dials. */ - provenance: "composio", - vendor: "Composio", + provenance: brokerProvider, + vendor: profile.vendor, addedBy: input.by, updatedAt: new Date(), /* @@ -678,8 +712,7 @@ export function serverMethods(context: StoreContext) { * insert above and this read disagree about what was stored; it is respelled rather than held * in a binding so that nothing below can take it by mistake. */ - const answering = - (await brokeredAppRow(input.slug))?.id ?? `composio-${input.slug}`; + const answering = (await brokeredAppRow(input.slug))?.id ?? rowId; /** * WHICH ROW EACH ACT OF AN ENABLE NAMES, WRITTEN DOWN BECAUSE NOTHING ELSE CAN SAY IT. @@ -763,41 +796,83 @@ export function serverMethods(context: StoreContext) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, input.slug), ), ) .limit(1); if (configured !== "standing" && connections.length === 0) { - /* - * MODE PICKED FOR THE PERSON ONLY WHERE THIS REWRITE IS WHAT GIVES THE APP AN ACCOUNT AT - * ALL, AND NEVER WHERE ONE WAS ALREADY CHOSEN. A row enabled `no-auth` has `accountMode` - * null — there was nothing to hold a mode about, see the insert above — and if this same - * re-add is what turns it into a key or consent app, it needs the ordinary default an - * insert would have given it, or every later read that asks "personal or shared" finds - * null and has no answer. An administrator who already chose Shared keeps that choice. - */ - const [current] = await database - .select({ accountMode: mcpServers.accountMode }) - .from(mcpServers) - .where(eq(mcpServers.id, answering)) - .limit(1); - const bumpAccountMode = - schemeKind(recorded) === "none" && - schemeKind(scheme) !== "none" && - (current?.accountMode ?? null) === null; - await database .update(mcpServers) - .set({ - authScheme: scheme, - updatedAt: new Date(), - ...(bumpAccountMode ? { accountMode: "personal" as const } : {}), - }) + .set({ authScheme: scheme, updatedAt: new Date() }) .where(eq(mcpServers.id, answering)); } + /* + * AN APP WITH AN ACCOUNT LEAVES THIS METHOD SAYING WHOSE, ALWAYS — AND THAT IS A SEPARATE + * STATEMENT FROM THE SCHEME ABOVE FOR A REASON. + * + * CRITERION. After `addBrokeredApp` returns, the row that answers for the app either records + * a mode or is an app with no account to hold one about ({@link schemeKind} `none`). There is + * no third state, and in particular no enabled key-or-consent app with a null here. + * + * REASON. The null is not a cosmetic gap: {@link accountFor} refuses a call against such a + * row as `ambiguous` — "does not say whether it is reached through each person's account or a + * shared one, so it was not called" — so an app in that state is enabled, listed, connectable + * and dead. THREE THINGS HAD TO BE TRUE AT ONCE FOR IT TO HAPPEN AND ALL THREE WERE. The + * INSERT above settles the column, but this method's second branch is the upsert's CONFLICT + * arm, which writes the url and the provenance — it CONVERTS a row that was not brokered + * into one that is — and never wrote this column at all. The guard that stood here then asked + * `schemeKind(recorded) === "none"`, which is the question "was this a no-auth app a moment + * ago"; `recorded` is {@link brokeredAppScheme}, and a row that was not brokered a moment ago + * answers `null`, which is `unreadable` rather than `none`. And the whole block was gated on + * `configured !== "standing"`, so the one obvious remedy — press Add again — reaches it only + * on the single press that created the config and never afterwards. An administrator + * converting a hand-added or restored row got an app that refused every call, and nothing + * they could press repaired it: the Plugins page offers "Make shared" for a null mode (it + * reads any non-`shared` value as Personal) and no way to say Personal at all. + * + * SO THE QUESTION ASKED HERE IS THE ONE THE REFUSAL ASKS, and nothing else: does the row + * answering for this app have an account to hold, and does it fail to say whose. The scheme + * is re-read rather than taken from `scheme` because the branch above may or may not have + * written it, and what matters is what now STANDS on the answering row — including the + * `unreadable` null a converted row carries, which {@link accountFor} treats as having an + * account and which is therefore exactly as dead without a mode as a recorded `OAUTH2`. + * + * `personal` IS THE FILL, which is the insert's own default and the conservative one: it + * reaches each asking person's own account. `shared` would hand everybody one account nobody + * chose to share. + * + * AND NEVER OVER A MODE THAT IS THERE. The `IS NULL` lives in the WHERE rather than in a + * read-then-write, so an administrator's Shared choice cannot be lost to this statement, nor + * to a switch racing it. + * + * ON THE ANSWERING ROW, WHICH IS WHY THE CONFLICT ARM STILL DOES NOT WRITE THE COLUMN. Every + * reader of a brokered app's mode — {@link brokeredAppRowsAt} and so `serverAddress`, the + * listing, the call gate — resolves it off the row that answers for the url, which is not + * always the row the upsert keyed on. A fill in the conflict arm would settle the minted row + * and leave the one that is actually read still null. + * + * ROWS ALREADY STUCK IN THE NULL STATE ARE NOT REACHED FROM HERE AT ALL, because an enabled + * app cannot be re-added without first being removed. They are repaired by + * `0059_settle_brokered_account_mode`, which is the same decision written once against the + * table; see `tests/brokered-account-mode-backfill.integration.test.ts`. + */ + const [settled] = await database + .select({ authScheme: mcpServers.authScheme }) + .from(mcpServers) + .where(eq(mcpServers.id, answering)) + .limit(1); + if (schemeKind(settled?.authScheme ?? null) !== "none") { + await database + .update(mcpServers) + .set({ accountMode: "personal", updatedAt: new Date() }) + .where( + and(eq(mcpServers.id, answering), isNull(mcpServers.accountMode)), + ); + } + await recordAuditEvent(auditStore, { eventType: "configuration.changed", targetType: "mcp_server", @@ -817,7 +892,7 @@ export function serverMethods(context: StoreContext) { // Named for the same reason the custom path names its own: "who enabled an app whose // actions nobody reviewed" is a question somebody will ask, and the answer should not // require knowing how ids were spelled in a past build. - provenance: "composio", + provenance: brokerProvider, }, }); @@ -978,7 +1053,9 @@ export function serverMethods(context: StoreContext) { * KEYED ON THE APP THE URL NAMES, exactly as `connectionTokenFor` keys the gate. `row.id` is a * display key and nothing holds it equal to the slug in the url, so a delete by id would clear * some other app's connections, or none, for the very row shape that gate already refuses to - * trust. `accessFor` is asked rather than the url parsed here, so this cannot drift from it. + * trust. The slug is read with {@link toolkitOfFor} — the one per-provider parser `accessFor` + * itself reads it with — so this cannot drift from what the gate resolves; see the comment on + * `rowProvider` below for why the PROVIDER is read beside it rather than the slug alone. * * ASKED WITH NO ENTRY, deliberately, and that is not the entry-wins order being dodged. An * entry can only ever SUPPRESS this answer — `accessFor` returns a null toolkit for every row @@ -1016,9 +1093,52 @@ export function serverMethods(context: StoreContext) { * deployment holds at Composio, not an account — while a live account whose config has * already been deleted is access that nothing left here can end. */ - const toolkit = existing ? accessFor(existing, null).toolkit : null; - - if (toolkit) { + /* + * AND ALL OF IT IS ABOUT THE BROKER THE ROW WAS MINTED AT, which is not always the one this + * deployment is configured with. + * + * CRITERION. One removal names ONE vendor. The app slug, the connection rows it clears and + * the account it ends are all that row's own broker's, and a vendor is asked only where it is + * the one this deployment can reach. + * + * REASON. These three were read three different ways in one operation: the slug off the ROW's + * provenance (through `accessFor`, which parses the url with the parser belonging to whatever + * that column says), the connection rows off the CONFIGURED `brokerProvider`, and the + * revocation off the ACTIVE broker. A deployment that has switched broker — or a restore, or a + * hand edit — holds rows whose provenance is the other vendor, and for one of those the three + * disagreed: a non-null FOREIGN slug went to `revoke` and `deleteAuthConfig` at the wrong + * vendor, naming one of its apps by a name that means something else there, while the deletes + * beneath matched no row at all. The removal then reported the connector gone, with every + * local consent row still standing and the right vendor never asked. + * + * SO THE ROW'S PROVENANCE DECIDES WHAT IS CLEARED, AND THE CONFIGURED PROVIDER DECIDES ONLY + * WHETHER A VENDOR IS ASKED. The local half always runs: the criterion two comments up — + * removing an app leaves nobody holding brokered access to it — is about this deployment's own + * gate, and it holds whichever broker minted the row. The vendor half is skipped where the + * row's broker is not the configured one, because there is no client for it here and no + * credential to reach it with; `vendorRevocationRequested` then says `false` on every trail + * row, which is exactly what that field is for and the same answer a deployment with no broker + * at all gets. An account left standing at a vendor this deployment can no longer reach is not + * a thing a removal can end, and saying so is better than asking the wrong vendor to. + * + * The parse is {@link toolkitOfFor} with the ROW's provider rather than `accessFor`, because + * what this needs is the pair — which broker, and which app at it — and `accessFor` answers + * only the second. It is the same answer for the same rows: `accessFor` reads the slug with + * the very same per-provider parser, under the same brokered-provenance test, and is asked + * with no entry here for the reason the comment above gives. + */ + const rowProvider = + existing && brokeredProvenance(existing.provenance) + ? existing.provenance + : null; + const toolkit = + existing && rowProvider + ? toolkitOfFor(rowProvider, existing.url) + : null; + /** The broker to ask, which is none at all where the row is the other vendor's. */ + const rowBroker = rowProvider === brokerProvider ? broker : null; + + if (rowProvider && toolkit) { /* * Read before anything is deleted, because the revokes below need the people and the rows * are where the people are. Sorted, so two removals of the same app revoke in the same @@ -1033,7 +1153,8 @@ export function serverMethods(context: StoreContext) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + // The row's own broker, not the configured one: see the comment above `rowProvider`. + eq(brokeredConnections.provider, rowProvider), eq(brokeredConnections.app, toolkit), ), ) @@ -1048,20 +1169,32 @@ export function serverMethods(context: StoreContext) { * which may still be standing after a switch back that has not retired it yet. Each is * revoked as the account its row says it is, under the vendor id the row was filed under, * so the deployment's account is asked for as the deployment's and never as whoever - * pressed "remove". A person row always carries its user id (the table's holder check), - * so the fallback to the deployment form below is the shape a corrupt row would need, not - * a reading anything here relies on. + * pressed "remove". + * + * READ THROUGH {@link asAccountRef}, AND A ROW THAT NAMES NO ACCOUNT STOPS THE REMOVAL + * BEFORE ANYTHING IS ASKED OR DELETED. This was a ternary whose else-branch absorbed every + * pair it did not recognise into `{ holder: "deployment" }`, and the two readings below — + * the revoke and the trail's `owner` — each repeated it. `brokered_connections_holder_check` + * makes `person` with a NULL user id unwritable, which is exactly why this is a refusal + * rather than a coercion: a state the database says cannot exist, absorbed into the other + * holder, is a withdrawal aimed at the DEPLOYMENT's account over a person's row. The + * EMPTY STRING is not hypothetical — `IS NOT NULL` does not stop `''` — and the old + * reading's `!== null` let it through as a person ref under nobody's id, which reached + * `rowBroker.revoke` and was filed as an `owner` of `""`. + * + * RESOLVED FOR THE WHOLE SET UP FRONT, so the throw lands before the first revoke rather + * than part-way through one: a removal that had already withdrawn two accounts at the + * vendor and then threw would leave their rows standing and no trail for either. */ - const accountOf = ( - connection: (typeof connected)[number], - ): AccountRef => - connection.holder === "person" && connection.userId !== null - ? { - holder: "person", - userId: connection.userId, - vendorUserId: connection.vendorUserId, - } - : { holder: "deployment", vendorUserId: connection.vendorUserId }; + const accounts = connected.map((connection): AccountRef => { + const account = asAccountRef(connection); + if (!account) { + throw new PluginInvariantError( + `${toolkit} holds a connection row whose holder and user id name no account, so the accounts standing for it cannot be ended.`, + ); + } + return account; + }); /* * What the broker was actually asked for each of them, kept so the trail below records the @@ -1070,24 +1203,56 @@ export function serverMethods(context: StoreContext) { * either way — but nothing was asked of Composio and the row must not claim otherwise. */ const vendorRevocationRequested: boolean[] = []; - for (const connection of connected) { + /* + * THE APPS EACH ACCOUNT'S REVOCATION ENDS BESIDES THIS ONE, read BEFORE anything is revoked + * and through {@link resolveSiblings}, the one place that answers it. Where a connection is + * keyed `(identity, auth provider)` — Arcade — withdrawing it ends every app sharing it, so a + * local row left for one of them claims an account the vendor no longer holds. On Composio the + * set is empty by construction and nothing below differs from what this method always did. + */ + const endedBesides: { siblings: string[]; unanswered: number }[] = []; + /* + * ONE AUTH-PROVIDER RESOLUTION PER TOOLKIT FOR THE WHOLE REMOVAL, not one per person. + * + * `connected` holds a row per PERSON who connected this app, and each `resolveSiblings` + * above resolves the asked toolkit plus every app that person holds. Unmemoized that is a + * live vendor call per (person × app) on Arcade — a removal on a deployment with two + * hundred people holding five apps each is over a thousand serial calls inside one admin + * request, which times out partway and leaves accounts revoked at the vendor with every + * local row still standing. Memoized, the bound is the number of DISTINCT TOOLKITS across + * those rows, whatever the number of people. + * + * The memo lives exactly as long as this removal and is dropped with it; see + * {@link resolveSiblings} for why one call's worth is semantically free. + */ + const authProviders = new Map>(); + for (const account of accounts) { + /* + * ASKED OF `rowBroker` AND NOT OF `broker`, both here and at the revoke below. + * {@link resolveSiblings} resolves against the CONFIGURED provider's rows and the active + * broker's `authProviderOf`, so asking it about a foreign row's app would be the same + * wrong-vendor question the revoke was asking. No broker for this row means nothing was + * asked, which is the answer the empty set and the `false` beside it already carry. + */ + endedBesides.push( + rowBroker + ? await resolveSiblings({ toolkit, account }, authProviders) + : { siblings: [], unanswered: 0 }, + ); vendorRevocationRequested.push( - broker - ? await broker.revoke({ account: accountOf(connection), toolkit }) - : false, + rowBroker ? await rowBroker.revoke({ account, toolkit }) : false, ); } - await database - .delete(brokeredConnections) - .where( - and( - eq(brokeredConnections.provider, "composio"), - eq(brokeredConnections.app, toolkit), - ), - ); + await database.delete(brokeredConnections).where( + and( + // The row's own broker, as the read above. + eq(brokeredConnections.provider, rowProvider), + eq(brokeredConnections.app, toolkit), + ), + ); - for (const [index, connection] of connected.entries()) { + for (const [index, account] of accounts.entries()) { await recordAuditEvent(auditStore, { eventType: "mcp.account_disconnected", targetType: "mcp_server", @@ -1116,11 +1281,11 @@ export function serverMethods(context: StoreContext) { server: toolkit, // Whose account this was, by the rule {@link disconnectBrokered} names it by: a // person's own under their id, the deployment's own as the deployment, never as - // the administrator who removed the app. + // the administrator who removed the app. Taken off the SAME `AccountRef` the revoke + // above was asked with — read once, by `asAccountRef` — so the trail cannot name a + // holder the withdrawal did not use. owner: - connection.holder === "person" && connection.userId !== null - ? connection.userId - : "deployment", + account.holder === "person" ? account.userId : "deployment", // The same three-way distinction the vault loop above draws, and the same answer: an // administrator took the whole app away and the person did nothing. reason: "mcp_server_removed", @@ -1140,9 +1305,46 @@ export function serverMethods(context: StoreContext) { }); } + /* + * THE SIBLINGS THE VENDOR ENDED WITH THIS APP, by the three rules written down once in + * {@link clearEndedSiblings} — the same call {@link disconnectBrokered} and + * {@link clearWithdrawnAccount} make. This loop used to spell rules 2 and 3 itself and so + * missed rule 1: a KEY sibling's row was deleted on the strength of an OAuth revoke that + * never ended it, leaving a live account at the vendor with no local row to reach it. + * + * WHAT DIFFERS FROM THOSE CALLERS IS ONLY THE ASKED APP, and it is handled above rather + * than here: a removal ends EVERY holder's row for `toolkit` in one delete keyed on the + * ROW's provider, where a disconnect ends one account's. So `endedWith` is `toolkit`, whose + * rows are already gone, and the helper never clears it. `revoked` is the vendor's raw + * answer per account, as `disconnectBrokered` passes its own `ended`. + * + * It sits after the revokes above for the same reason the asked app's rows do: an exception + * from any of them left every row standing. AND IT CAN CLEAR ONLY THE CONFIGURED PROVIDER'S + * OWN ROWS, which is what keeps the helper's `accountRow` delete — keyed on `brokerProvider` + * — about the same broker as the siblings it deletes: `revoked` true requires `rowBroker`, + * and there is no `rowBroker` for a row the other broker minted. + */ + for (const [index, account] of accounts.entries()) { + const { siblings, unanswered } = endedBesides[index] ?? { + siblings: [], + unanswered: 0, + }; + await clearEndedSiblings({ + siblings, + account, + endedWith: toolkit, + revoked: vendorRevocationRequested[index] ?? false, + unanswered, + by, + reason: "mcp_server_removed", + }); + } + // Last of all, for the reason above, and skipped entirely on a deployment with no - // broker to have made one. - if (broker) await broker.deleteAuthConfig(toolkit); + // broker to have made one — or, for a row of the other broker's, no broker of ITS that + // this deployment can reach. Nothing here holds an auth config at a vendor it is not + // configured for, and this slug is not a name that vendor's apps go by. + if (rowBroker) await rowBroker.deleteAuthConfig(toolkit); } const released = await database.transaction(async (transaction) => { @@ -1660,7 +1862,9 @@ export function serverMethods(context: StoreContext) { * ONE EXTRA READ FOR THE WHOLE LIST, and none where the deployment has enabled no apps. */ const brokeredApps = await brokeredAppRowsAt( - rows.filter((row) => toolkitOf(row.url) !== null).map((row) => row.url), + rows + .filter((row) => toolkitOfFor(brokerProvider, row.url) !== null) + .map((row) => row.url), ); /* @@ -1675,7 +1879,7 @@ export function serverMethods(context: StoreContext) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.holder, "deployment"), ), ) @@ -1684,7 +1888,7 @@ export function serverMethods(context: StoreContext) { return rows.map((row) => { const entry = catalogueEntry(row.id); - const toolkit = toolkitOf(row.url); + const toolkit = toolkitOfFor(brokerProvider, row.url); return { id: row.id, title: row.title, @@ -1807,7 +2011,7 @@ export function serverMethods(context: StoreContext) { .where(eq(mcpServers.id, serverId)) .limit(1); if (!row) return undefined; - const toolkit = toolkitOf(row.url); + const toolkit = toolkitOfFor(brokerProvider, row.url); if (!toolkit) return { ...row, accountMode: null }; const app = await brokeredAppRow(toolkit); return { @@ -1838,7 +2042,20 @@ export function serverMethods(context: StoreContext) { return rows.map((row) => row.url); }, - /** Every account any holder has on a brokered app, as the broker would be asked to end it. */ + /** + * Every account any holder has on a brokered app, as the broker would be asked to end it. + * + * READ THROUGH {@link asAccountRef}, AND A ROW THAT NAMES NO ACCOUNT STOPS THE READ. Every + * account handed back here is passed straight to {@link disconnectBrokered} by the mode + * switch, so what is minted here is what a revoke is aimed at and what the trail records. The + * reading this replaces was `userId: row.userId ?? ""`, the quiet direction of the same + * coercion `asAccountRef` exists to refuse: a person row carrying no user id came out as a + * person ref belonging to NOBODY and was withdrawn under an owner of `""`. `''` is the pair + * that is actually reachable — `brokered_connections_holder_check` tests `IS NOT NULL`, which + * `''` satisfies — and `null` is unwritable, which is why both are a refusal rather than a + * default. The `null` answer below still means only "not a brokered app at this broker"; + * a row that cannot be read is this deployment contradicting its own CHECK, and says so. + */ async accountsOn( serverId: string, ): Promise<{ toolkit: string; accounts: AccountRef[] } | null> { @@ -1847,7 +2064,7 @@ export function serverMethods(context: StoreContext) { .from(mcpServers) .where(eq(mcpServers.id, serverId)) .limit(1); - const toolkit = row ? toolkitOf(row.url) : null; + const toolkit = row ? toolkitOfFor(brokerProvider, row.url) : null; if (!toolkit) return null; const held = await database .select({ @@ -1858,22 +2075,21 @@ export function serverMethods(context: StoreContext) { .from(brokeredConnections) .where( and( - eq(brokeredConnections.provider, "composio"), + eq(brokeredConnections.provider, brokerProvider), eq(brokeredConnections.app, toolkit), ), ); return { toolkit, - accounts: held.map( - (row): AccountRef => - row.holder === "person" - ? { - holder: "person", - userId: row.userId ?? "", - vendorUserId: row.vendorUserId, - } - : { holder: "deployment", vendorUserId: row.vendorUserId }, - ), + accounts: held.map((row): AccountRef => { + const account = asAccountRef(row); + if (!account) { + throw new PluginInvariantError( + `${toolkit} holds a connection row whose holder and user id name no account, so the accounts standing for it cannot be listed.`, + ); + } + return account; + }), }; }, @@ -1896,7 +2112,9 @@ export function serverMethods(context: StoreContext) { .from(mcpServers) .where(eq(mcpServers.id, serverId)) .limit(1); - const toolkit = dialled ? toolkitOf(dialled.url) : null; + const toolkit = dialled + ? toolkitOfFor(brokerProvider, dialled.url) + : null; const app = toolkit ? await brokeredAppRow(toolkit) : null; await database .update(mcpServers) diff --git a/server/src/plugins/plugin-store/skills.ts b/server/src/plugins/plugin-store/skills.ts index 39ca3dec5..159e16f2f 100644 --- a/server/src/plugins/plugin-store/skills.ts +++ b/server/src/plugins/plugin-store/skills.ts @@ -4,6 +4,7 @@ import { agentProfiles, agents, mcpServers, + mcpTools, pluginGrants, skills, skillTools, @@ -126,6 +127,27 @@ export function skillMethods(context: StoreContext) { return row !== undefined; }, + /** + * Whether this server publishes an action named EXACTLY this. + * + * One indexed read on `mcp_tools`' `(server_id, name)` key, and deliberately not `listServers`, + * which materialises every tool and every grant in the deployment to answer. The comparison is + * `=` on the stored text and so CASE-SENSITIVE: Arcade names are dotted and case-sensitive and + * are never normalized, and `Gmail.SendEmail` is not `gmail.sendemail`. + */ + async serverPublishesAction( + serverId: string, + action: string, + ): Promise { + if (!serverId || !action) return false; + const [row] = await database + .select({ name: mcpTools.name }) + .from(mcpTools) + .where(and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, action))) + .limit(1); + return row !== undefined; + }, + async agentOwner(agentId: string): Promise { const [row] = await database .select({ ownerUserId: agentProfiles.ownerUserId }) @@ -186,12 +208,26 @@ export function skillMethods(context: StoreContext) { }) // Editing keeps the owner it already had. Whose a skill is, is not something a re-save // should quietly change, and the route has already checked this person may edit it. + // `installed_by` is left alone too: it records who first installed it, not who last saved. .onConflictDoUpdate({ target: skills.slug, set: { title: input.title, summary: input.summary, instructions: input.instructions, + /* + * WHERE THE TEXT NOW COMES FROM, WRITTEN BESIDE THE TEXT. + * + * CRITERION. A save through here leaves `origin` saying what it says on an insert. + * + * REASON. `origin = 'catalogue'` is the condition the package seed (`tenant-package.ts`, + * `setWhere`) overwrites a skill on at every boot. Leaving it standing after somebody + * edited a shipped skill here kept the row eligible, so the next boot silently replaced + * their edit with the package's text — the exact outcome that seed's own comment says + * it exists to prevent ("rather than the package silently replacing something somebody + * wrote"). + */ + origin: input.origin ?? "yours", updatedAt: new Date(), }, }); diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts index 5c23a242b..16a38c744 100644 --- a/server/src/plugins/route-groups/composio-apps.ts +++ b/server/src/plugins/route-groups/composio-apps.ts @@ -61,7 +61,10 @@ export function mountComposioAppRoutes({ if (!composio) { return context.json( - { error: new BrokerUnconfiguredError().message }, + { + error: new BrokerUnconfiguredError(store.brokerProvider, "operator") + .message, + }, 503, ); } @@ -148,7 +151,10 @@ export function mountComposioAppRoutes({ if (!composio) { return context.json( - { error: new BrokerUnconfiguredError().message }, + { + error: new BrokerUnconfiguredError(store.brokerProvider, "operator") + .message, + }, 503, ); } diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts index 7ef106608..2b09725fb 100644 --- a/server/src/plugins/route-groups/connections.ts +++ b/server/src/plugins/route-groups/connections.ts @@ -200,7 +200,10 @@ export function mountConnectionRoutes({ */ if (!composio) { return context.json( - { error: new BrokerUnconfiguredError().message }, + { + error: new BrokerUnconfiguredError(store.brokerProvider, "person") + .message, + }, 503, ); } @@ -726,6 +729,7 @@ export function mountConnectionRoutes({ */ returnUrl: brokerReturnUrl( connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + store.brokerProvider, ), })); } catch (error) { @@ -900,7 +904,11 @@ export function mountConnectionRoutes({ } if (!composio) { return { - refusal: { error: new BrokerUnconfiguredError().message, status: 503 }, + refusal: { + error: new BrokerUnconfiguredError(store.brokerProvider, "person") + .message, + status: 503, + }, }; } const resolved = await store.accountRefFor(serverId, context.var.actor.id); diff --git a/server/src/plugins/route-groups/context.ts b/server/src/plugins/route-groups/context.ts index 87635f5f8..89939e659 100644 --- a/server/src/plugins/route-groups/context.ts +++ b/server/src/plugins/route-groups/context.ts @@ -1,6 +1,7 @@ import type { Hono, MiddlewareHandler } from "hono"; import type { BotAccessCheck } from "../../agents/profile-policy"; import type { AppVariables } from "../../auth/guards"; +import type { BrokerProvider } from "../access"; import type { AccountModeSwitch } from "../account-mode"; import { brokerSentence, type ComposioBroker } from "../broker"; import { vendorSentence } from "../composio"; @@ -123,7 +124,7 @@ export type PluginRouteContext = { requireUser: MiddlewareHandler<{ Variables: AppVariables }>; canUseBot: BotAccessCheck; connect: PluginConnectOptions | undefined; - composio: { broker: ComposioBroker } | undefined; + composio: { broker: ComposioBroker; provider: BrokerProvider } | undefined; shared: { modes: AccountModeSwitch; use: SharedUseStore } | undefined; actorEmail: (context: { var: AppVariables }) => string; skillActor: (context: { var: AppVariables }) => { diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index b3cebe4ba..a5aa59440 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -2,6 +2,7 @@ import { Hono, type MiddlewareHandler } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import type { AccountModeSwitch } from "./account-mode"; +import type { BrokerProvider } from "./access"; import type { ComposioBroker } from "./broker"; import { mountComposioAppRoutes } from "./route-groups/composio-apps"; import { mountConnectionRoutes } from "./route-groups/connections"; @@ -72,7 +73,7 @@ export function createPluginRoutes( * trap `connect` documents applies here with one more argument in it — every parameter from that * position on is optional, so a misplaced one typechecks and quietly does nothing. */ - composio?: { broker: ComposioBroker }, + composio?: { broker: ComposioBroker; provider: BrokerProvider }, /** * Who may use an app's one shared account, and the switch that makes an app Shared or Personal. * diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 556bedac7..e74eb9e77 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -9,9 +9,12 @@ import { skillMethods } from "./plugin-store/skills"; export type { CatalogueEntry } from "./catalogue"; export type { AccessToken, + BrokeredDisconnectReason, BrokeredProbe, + ConnectionFootprint, GrantedPlugins, OAuthClient, + PendingAuthorization, PluginDecision, PluginKind, PluginStoreOptions, @@ -25,6 +28,7 @@ export type { export { BROKERED_PROBE_OUTCOMES, CatalogueEntryUnknownError, + connectionSiblings, CustomServerRefusedError, deploymentFaultSentence, exchangeRefreshTokenOverHttp, diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts index 20d5efb78..cf7497ee1 100644 --- a/server/tests/assertion-shape.test.ts +++ b/server/tests/assertion-shape.test.ts @@ -716,7 +716,7 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/headless-components.integration.test.ts", - line: 341, + line: 349, shape: "b", finding: "NEW (round 2 did not find this)", why: "expect(bar?.hidden).toBeUndefined();", @@ -863,14 +863,14 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/routine-runner.test.ts", - line: 168, + line: 169, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect(recorded.finished).toEqual([ { runId: RUN_ID, status: "succeeded", error: undefined }, ]);', }, { file: "server/tests/routine-runner.test.ts", - line: 271, + line: 272, shape: "c", finding: "NEW (round 2 did not find this)", why: 'expect(recorded.finished).toEqual([ { runId: RUN_ID, status: "succeeded", error: undefined }, ]);', @@ -921,7 +921,7 @@ const ALLOW: Allowed[] = [ }, { file: "server/tests/enterprise-controls.integration.test.ts", - line: 58, + line: 59, shape: "e", finding: "NEW (round 2 did not find this)", why: "const stopped: string[] = []; — pushed to at :65, cleared at :718, read nowhere; the `.stopped` at :704 and :720 is the response's own field", diff --git a/server/tests/broker-authorization-pairing.test.ts b/server/tests/broker-authorization-pairing.test.ts index 8f029197e..697a7fb2b 100644 --- a/server/tests/broker-authorization-pairing.test.ts +++ b/server/tests/broker-authorization-pairing.test.ts @@ -229,7 +229,9 @@ describe("authorizations and authorizationId are declared as a pair", () => { coverage.some((p) => p.startsWith("src/plugins/arcade-adapter.ts")), ).toBe(true); expect( - coverage.some((p) => p.startsWith("src/plugins/composio-adapter.ts")), + coverage.some((p) => + p.startsWith("src/plugins/composio-client/consent.ts"), + ), ).toBe(true); // A floor that notices this scan collapsing, not a count to keep up to date; it sits just under // what the tree holds today (28) rather than at a third of it. diff --git a/server/tests/sole-definition.test.ts b/server/tests/sole-definition.test.ts index 601b50326..b47b65aca 100644 --- a/server/tests/sole-definition.test.ts +++ b/server/tests/sole-definition.test.ts @@ -104,19 +104,19 @@ const TRACKED: Tracked[] = [ { decision: "sibling-cascade-rules", names: ["clearEndedSiblings"], - home: "server/src/plugins/store.ts", + home: "server/src/plugins/plugin-store/context.ts", }, /* Which apps a revoke ends besides the one it names: "the one place that answers it". */ { decision: "sibling-resolution", names: ["resolveSiblings"], - home: "server/src/plugins/store.ts", + home: "server/src/plugins/plugin-store/context.ts", }, /* Whether an app is a key, consent or neither, read off `mcp_servers`; every rule 1 asks it. */ { decision: "brokered-app-kind", names: ["brokeredAppKind"], - home: "server/src/plugins/store.ts", + home: "server/src/plugins/plugin-store/context.ts", }, /* The migrator's journal table, named once (C4). Its literal spellings are in SPELLED below. */ { @@ -158,7 +158,7 @@ const KNOWN_DUPLICATES: Record< "app/src/lib/plugins/grants.ts", "server/src/plugins/arcade-adapter.ts", "server/src/plugins/arcade.ts", - "server/src/plugins/composio-adapter.ts", + "server/src/plugins/composio-vendor.ts", ], }, }; @@ -265,8 +265,10 @@ test("a definition is matched, and a call or a mention is not", () => { * once per logical line that spells the decision — against either the home or the debt on record. * * WHAT A PATH KEY CANNOT SAY. Two spellings in one file are that path listed twice; which of the two - * is the home is not something a path can carry. For A5b and A5c the second spelling is in the SAME - * file as the first (`plugins/store.ts`), so the guard holds the COUNT: a third spelling anywhere, + * is the home is not something a path can carry. For A5c the second spelling is in the SAME file as + * the first (`plugin-store/connections.ts`; both A5b and A5c were in `plugins/store.ts` before #784 + * split it, and A5b's two now sit in `context.ts` and `connections.ts`), so the guard holds the + * COUNT: a third spelling anywhere, * that file included, goes red; collapsing one goes red until the list drops a line. Moving the * spelling from the owning function into a sibling function of the same file does not. That is the * price of not going red on a rewrapped comment, and the same price the name table already pays. @@ -332,7 +334,7 @@ const SPELLED: Spelled[] = [ // the rows of one app at one url, which is not a revoke's answer and not this decision. /\.siblings\s*(?:\?\.|\.)\s*(?:forEach|map|flatMap|filter|reduce|some|every|find)\s*\(/, ], - home: ["server/src/plugins/store.ts"], + home: ["server/src/plugins/plugin-store/context.ts"], }, /* * WHAT THE TRAIL SAYS WAS ASKED OF THE VENDOR, for the app an act was ABOUT. A key app's answer @@ -347,7 +349,7 @@ const SPELLED: Spelled[] = [ patterns: [ /\bvendorRevocationRequested\s*[:=]\s*(?!true\b|false\b|boolean\b)\S/, ], - home: ["server/src/plugins/store.ts"], + home: ["server/src/plugins/plugin-store/connections.ts"], }, ]; @@ -366,16 +368,19 @@ const KNOWN_SECOND_SPELLINGS: Record< "sibling-cascade-walk": { finding: "A5b (round 2, wave 3) — open", why: "`retireConnectionsFor` builds its `endedWith` map by walking the siblings itself, with rule 2 (`if (!entry.requested) continue`) and without rule 1, so a key app's trail row can name an OAuth revoke as what ended it. Its shape is an annotation, not a delete, which is why it was not routed through `clearEndedSiblings` with the other three.", - sites: ["server/src/plugins/store.ts", "server/src/plugins/store.ts"], + sites: [ + "server/src/plugins/plugin-store/context.ts", + "server/src/plugins/plugin-store/connections.ts", + ], }, "key-app-revoke-answer": { finding: "A5c (round 2, wave 3) — open; the `retireConnectionsFor` line is the same divergence, found by L5", why: "`removeServer` writes `vendorRevocationRequested[index] ?? false` — the vendor's raw answer — for the removed app even when it is a key app, and `retireConnectionsFor` writes `connection.requested || Boolean(endedBy)`, also raw for a key app; `disconnectBrokered` writes `false` for a key. One field, three answers.", sites: [ - "server/src/plugins/store.ts", - "server/src/plugins/store.ts", - "server/src/plugins/store.ts", + "server/src/plugins/plugin-store/connections.ts", + "server/src/plugins/plugin-store/connections.ts", + "server/src/plugins/plugin-store/servers.ts", ], }, }; diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index 1e38876ec..aa57ceb3a 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -81,7 +81,7 @@ server/tests/component-decision.test.ts:30: error TS2769: No overload matches th server/tests/composio-connection-kinds.test.ts:1908: error TS2769: No overload matches this call. server/tests/composio-connection-kinds.test.ts:1910: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"key" | "none" | "consent" | "unreadable", Cell>'. server/tests/composio-connection-kinds.test.ts:1924: error TS2769: No overload matches this call. -server/tests/composio-connection-kinds.test.ts:1926: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"unreachable" | "answered" | "nothing" | "complained", Cell>'. +server/tests/composio-connection-kinds.test.ts:1926: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"answered" | "unreachable" | "nothing" | "complained", Cell>'. server/tests/composio-connection-kinds.test.ts:327: error TS2769: No overload matches this call. server/tests/composio-connection-kinds.test.ts:481: error TS2769: No overload matches this call. server/tests/composio-connections.test.ts:4417: error TS2769: No overload matches this call. @@ -102,23 +102,23 @@ server/tests/computer-policy.test.ts:551: error TS2741: Property 'initiator' is server/tests/computer-policy.test.ts:640: error TS2345: Argument of type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; intent: "activate"; key: string; element: { ref: string; role: string; name: string; type: string; }; file: { ...; }; command: string; mcp: { ...; }; }' is not assignable to parameter of type 'PolicyContext'. server/tests/computer-policy.test.ts:662: error TS2345: Argument of type '{ tool: { name: string; }; bot: { id: string; }; actor: { id: string; }; page: { url: string; host: string; }; key: string; element: { ref: string; role: string; name: string; type: string; }; file: { path: string; name: string; extension: string; }; command: string; mcp: { ...; }; }' is not assignable to parameter of type 'PolicyContext'. server/tests/computer-provider.test.ts:418: error TS2345: Argument of type '{ provider: "sandbox"; namespace: string; idleAfterMs: number; templateFile: string; }' is not assignable to parameter of type 'ComputerConfig'. -server/tests/copilot.test.ts:1065: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. -server/tests/copilot.test.ts:1103: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. -server/tests/copilot.test.ts:1176: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. -server/tests/copilot.test.ts:1204: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. -server/tests/copilot.test.ts:1262: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. -server/tests/copilot.test.ts:1306: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. -server/tests/copilot.test.ts:153: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. -server/tests/copilot.test.ts:157: error TS2739: Type '{ id: string; name: string; type: "built_in"; systemPrompt: string; }[]' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] -server/tests/copilot.test.ts:346: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. -server/tests/copilot.test.ts:387: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. -server/tests/copilot.test.ts:425: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. -server/tests/copilot.test.ts:444: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. -server/tests/copilot.test.ts:477: error TS2322: Type 'Promise<{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]>' is not assignable to type 'Promise'. -server/tests/copilot.test.ts:524: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. -server/tests/copilot.test.ts:529: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. -server/tests/copilot.test.ts:576: error TS2322: Type 'Promise<{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]>' is not assignable to type 'Promise'. -server/tests/copilot.test.ts:697: error TS2345: Argument of type '() => never' is not assignable to parameter of type 'typeof fetch'. +server/tests/copilot.test.ts:1090: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1128: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1201: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1229: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1287: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. +server/tests/copilot.test.ts:1331: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/copilot.test.ts:154: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/copilot.test.ts:158: error TS2739: Type '{ id: string; name: string; type: "built_in"; systemPrompt: string; }[]' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] +server/tests/copilot.test.ts:371: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. +server/tests/copilot.test.ts:412: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. +server/tests/copilot.test.ts:450: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:469: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:502: error TS2322: Type 'Promise<{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]>' is not assignable to type 'Promise'. +server/tests/copilot.test.ts:549: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:554: error TS2345: Argument of type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]' is not assignable to parameter of type 'RegisteredAgent[]'. +server/tests/copilot.test.ts:601: error TS2322: Type 'Promise<{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }[]>' is not assignable to type 'Promise'. +server/tests/copilot.test.ts:722: error TS2345: Argument of type '() => never' is not assignable to parameter of type 'typeof fetch'. server/tests/credentials.test.ts:139: error TS2345: Argument of type '{ encryptionKey: string; store: { create: () => Promise<{ id: string; revokedAt: null; }>; updateSecret: () => Promise; rotate: (input: { previousCredentialId: string; }) => Promise<...>; revoke: (id: string) => Promise<...>; }; auditStore: { ...; }; }' is not assignable to parameter of type 'CredentialService'. server/tests/credentials.test.ts:148: error TS2345: Argument of type '{ encryptionKey: string; store: { create: () => Promise<{ id: string; revokedAt: null; }>; updateSecret: () => Promise; rotate: (input: { previousCredentialId: string; }) => Promise<...>; revoke: (id: string) => Promise<...>; }; auditStore: { ...; }; }' is not assignable to parameter of type 'CredentialService'. server/tests/credentials.test.ts:192: error TS2345: Argument of type '{ encryptionKey: string; store: { create: () => Promise; rotate: () => Promise; revoke: () => Promise; }; auditStore: { insert: (event: unknown) => Promise<...>; }; }' is not assignable to parameter of type 'CredentialService'. @@ -133,25 +133,25 @@ server/tests/delivery-router.test.ts:398: error TS2322: Type 'Promise<{ id: stri server/tests/delivery-router.test.ts:401: error TS2322: Type '{ findBinding: (_source: string, realm: string, identity: string, address?: string) => Promise<{ ownerUserId: string; channelId: string; agentId: string; threadId: string; id: string; ... 6 more ...; optedOutAt?: Date | null; } | null>; ... 15 more ...; botIdentity: (agentId: string) => Promise<...>; }' is not assignable to type 'RouterStore'. server/tests/delivery-routes.test.ts:87: error TS2322: Type '(input: Omit) => Promise<{ identity: string; agentId: string; threadId: string; ownerUserId: string; channelId: string; transport: BindingTransport; ... 5 more ...; enabled: boolean; }>' is not assignable to type '(input: Omit) => Promise<{ identity: string; id: string; createdAt: Date; agentId: string; threadId: string; ownerUserId: string; ... 6 more ...; optedOutAt: Date | null; }>'. server/tests/delivery-routes.test.ts:95: error TS2322: Type 'Promise' is not assignable to type 'Promise<{ transport: BindingTransport; identity: string; realm: string; address: string; mentionId: string | null; enabled: boolean; optedOutAt: Date | null; createdAt: Date; ... 4 more ...; id: string; }>'. -server/tests/delivery-turn.test.ts:68: error TS2322: Type 'string | ContentPart[]' is not assignable to type 'string'. +server/tests/delivery-turn.test.ts:70: error TS2322: Type 'string | ContentPart[]' is not assignable to type 'string'. server/tests/dictation-routes.test.ts:30: error TS2739: Type 'Response & TypedResponse<{ error: string; }, 401, "json">' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] server/tests/encrypt-sso-config.test.ts:139: error TS2554: Expected 0 arguments, but got 1. server/tests/encrypt-sso-config.test.ts:74: error TS2554: Expected 0 arguments, but got 1. server/tests/encrypt-sso-config.test.ts:85: error TS2554: Expected 0 arguments, but got 1. -server/tests/enterprise-controls.integration.test.ts:143: error TS2769: No overload matches this call. -server/tests/enterprise-controls.integration.test.ts:146: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. +server/tests/enterprise-controls.integration.test.ts:144: error TS2769: No overload matches this call. +server/tests/enterprise-controls.integration.test.ts:148: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. server/tests/enterprise-controls.integration.test.ts:147: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. server/tests/google-drive-rest.test.ts:108: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. server/tests/google-drive-rest.test.ts:122: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. server/tests/group-conversations.integration.test.ts:361: error TS2339: Property 'serverId' does not exist on type 'object'. server/tests/headless-components.integration.test.ts:12: error TS6142: Module '../../app/src/components/gallery/charts' was resolved to 'app/src/components/gallery/charts.tsx', but '--jsx' is not set. -server/tests/headless-components.integration.test.ts:176: error TS2741: Property 'preconnect' is missing in type '(url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. -server/tests/headless-components.integration.test.ts:265: error TS7006: Parameter 'entry' implicitly has an 'any' type. -server/tests/headless-components.integration.test.ts:282: error TS2352: Conversion of type 'BaseEvent' to type '{ delta: string; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. -server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'description' implicitly has an 'any' type. -server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'kind' implicitly has an 'any' type. -server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'name' implicitly has an 'any' type. -server/tests/headless-components.integration.test.ts:54: error TS7031: Binding element 'title' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:180: error TS2741: Property 'preconnect' is missing in type '(url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. +server/tests/headless-components.integration.test.ts:273: error TS7006: Parameter 'entry' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:290: error TS2352: Conversion of type 'BaseEvent' to type '{ delta: string; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. +server/tests/headless-components.integration.test.ts:58: error TS7031: Binding element 'description' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:58: error TS7031: Binding element 'kind' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:58: error TS7031: Binding element 'name' implicitly has an 'any' type. +server/tests/headless-components.integration.test.ts:58: error TS7031: Binding element 'title' implicitly has an 'any' type. server/tests/headless-components.test.ts:104: error TS7006: Parameter 'entry' implicitly has an 'any' type. server/tests/headless-components.test.ts:10: error TS6142: Module '../../app/src/components/gallery/quote' was resolved to 'app/src/components/gallery/quote.tsx', but '--jsx' is not set. server/tests/headless-components.test.ts:11: error TS6142: Module '../../app/src/components/gallery/table' was resolved to 'app/src/components/gallery/table.tsx', but '--jsx' is not set. @@ -160,8 +160,8 @@ server/tests/headless-components.test.ts:6: error TS6142: Module '../../app/src/ server/tests/headless-components.test.ts:7: error TS6142: Module '../../app/src/components/gallery/charts' was resolved to 'app/src/components/gallery/charts.tsx', but '--jsx' is not set. server/tests/headless-components.test.ts:8: error TS6142: Module '../../app/src/components/gallery/decisions' was resolved to 'app/src/components/gallery/decisions.tsx', but '--jsx' is not set. server/tests/headless-components.test.ts:9: error TS6142: Module '../../app/src/components/gallery/form' was resolved to 'app/src/components/gallery/form.tsx', but '--jsx' is not set. -server/tests/headless-tools.test.ts:125: error TS2322: Type '(context: { ownerUserId: string; agentId: string; initiator: { kind: string; id?: string | undefined; }; }) => Promise' is not assignable to type '(input: { ownerUserId: string; agentId: string; initiator: AuditInitiator; threadId: string; runId: string; }) => Promise'. -server/tests/headless-tools.test.ts:296: error TS2741: Property 'preconnect' is missing in type '(url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. +server/tests/headless-tools.test.ts:127: error TS2322: Type '(context: { ownerUserId: string; agentId: string; initiator: { kind: string; id?: string | undefined; }; }) => Promise' is not assignable to type '(input: { ownerUserId: string; agentId: string; initiator: AuditInitiator; threadId: string; runId: string; }) => Promise'. +server/tests/headless-tools.test.ts:298: error TS2741: Property 'preconnect' is missing in type '(url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. server/tests/health.test.ts:140: error TS2345: Argument of type '{ handler: () => Response; }' is not assignable to parameter of type 'AuthService'. server/tests/identity-provider-audit.test.ts:28: error TS2322: Type 'Promise' is not assignable to type 'Promise'. server/tests/identity-provider-audit.test.ts:95: error TS7006: Parameter 'event' implicitly has an 'any' type. @@ -196,13 +196,13 @@ server/tests/plugin-store.integration.test.ts:6128: error TS2322: Type '{ readSe server/tests/plugin-store.integration.test.ts:6257: error TS2556: A spread argument must either have a tuple type or be passed to a rest parameter. server/tests/plugin-store.integration.test.ts:6264: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/plugin-store.integration.test.ts:6399: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/plugin-store.integration.test.ts:6548: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. -server/tests/plugin-store.integration.test.ts:6727: error TS2345: Argument of type '{ slug: string; title: string; by: string; }' is not assignable to parameter of type '{ slug: string; title: string; logo?: string | null | undefined; by: string; connection: BrokerConnection; }'. -server/tests/plugin-store.integration.test.ts:6812: error TS2322: Type '(request: { toolkit: string; name: string; }) => Promise' is not assignable to type '(config: { toolkit: string; name: string; connection: BrokerConnection; }) => Promise'. -server/tests/plugin-store.integration.test.ts:7014: error TS2345: Argument of type 'string | null' is not assignable to parameter of type 'string'. -server/tests/plugin-store.integration.test.ts:7338: error TS2322: Type '() => Promise' is not assignable to type '(config: { toolkit: string; name: string; }) => Promise'. -server/tests/plugin-store.integration.test.ts:7341: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. -server/tests/plugin-store.integration.test.ts:7345: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. +server/tests/plugin-store.integration.test.ts:6559: error TS2322: Type '{ readSecret: () => Promise; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. +server/tests/plugin-store.integration.test.ts:6738: error TS2345: Argument of type '{ slug: string; title: string; by: string; }' is not assignable to parameter of type '{ slug: string; title: string; logo?: string | null | undefined; by: string; connection: BrokerConnection; }'. +server/tests/plugin-store.integration.test.ts:6823: error TS2322: Type '(request: { toolkit: string; name: string; }) => Promise' is not assignable to type '(config: { toolkit: string; name: string; connection: BrokerConnection; }) => Promise'. +server/tests/plugin-store.integration.test.ts:7025: error TS2345: Argument of type 'string | null' is not assignable to parameter of type 'string'. +server/tests/plugin-store.integration.test.ts:7349: error TS2322: Type '() => Promise' is not assignable to type '(config: { toolkit: string; name: string; }) => Promise'. +server/tests/plugin-store.integration.test.ts:7352: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. +server/tests/plugin-store.integration.test.ts:7356: error TS2345: Argument of type '(string | null)[]' is not assignable to parameter of type 'string[]'. server/tests/plugin-user-credential.integration.test.ts:98: error TS2322: Type '{ readSecret: (id: string) => Promise<{ encryptedValue: string; revokedAt: Date | null; }>; create: () => Promise; updateSecret: () => Promise; revoke: (id: string) => Promise<...>; }' is not assignable to type 'CredentialSecretReader & CredentialStore'. server/tests/policy-dry-run.test.ts:24: error TS2739: Type '{ id: string; actorUserId: null; eventType: string; targetType: string; targetId: string; createdAt: string; payload: Record; }' is missing the following properties from type 'AuditEvent': initiatorKind, initiatorId server/tests/remote-approval-context.test.ts:11: error TS2345: Argument of type '(input: RunAgentInput) => Observable<{ type: string; threadId: string; runId: string; } | { type: string; snapshot: { real: string; }; } | { type: string; toolCallId: string; toolCallName: string; parentMessageId: string; } | { ...; } | { ...; } | { ...; } | { ...; }>' is not assignable to parameter of type '(input: RunAgentInput) => Observable'. @@ -229,9 +229,9 @@ server/tests/routes-vendor-vocabulary.test.ts:270: error TS2322: Type 'Response server/tests/routes-vendor-vocabulary.test.ts:274: error TS2322: Type 'Response | Promise' is not assignable to type 'Promise'. server/tests/routine-routes.test.ts:45: error TS2739: Type '{ create(): Promise; listFor(ownerUserId: string): Promise; update(): Promise; remove(ownerUserId: string, id: string): Promise<...>; ... 9 more ...; consecutiveFailures(): Promise<...>; }' is missing the following properties from type 'RoutineStore': startManualRun, listRuns, reapAbandonedRuns, markUnschedulable server/tests/routine-run-now.test.ts:80: error TS2322: Type '() => Promise<({ id: string; status: null; startedAt: Date; finishedAt: null; error: null; } | { id: string; status: "failed"; startedAt: Date; finishedAt: Date; error: string; })[]>' is not assignable to type '(ownerUserId: string, id: string, limit?: number | undefined) => Promise'. -server/tests/routine-runner.test.ts:110: error TS2739: Type '{ create: () => never; list: () => never; setPinned: () => never; markRead: () => never; softDelete: () => never; get(actor: AgentActor, channelId: string): Promise; recordActivity(actor: AgentActor, channelId: string, activity: ChannelActivity): Promise<...>; }' is missing the following properties from type 'ChannelStore': direct, signalBusy, signalChannelBusy -server/tests/routine-runner.test.ts:39: error TS2741: Property 'lastMessageAt' is missing in type '{ id: string; name: string; agentIds: string[]; threadId: string; active: true; }' but required in type 'AgentChannel'. -server/tests/routine-runner.test.ts:78: error TS2740: Type '{ create: () => never; listFor: () => never; update: () => never; remove: () => never; dueRoutines: () => never; advanceNextRun: () => never; insertRun: () => never; runContext(runId: string): Promise; finishRun(runId: string, status: RoutineRunOutcome, error: string | undefined): Promise<....' is missing the following properties from type 'RoutineStore': startManualRun, listRuns, routineForFiring, reapAbandonedRuns, and 3 more. +server/tests/routine-runner.test.ts:111: error TS2739: Type '{ create: () => never; list: () => never; setPinned: () => never; markRead: () => never; softDelete: () => never; get(actor: AgentActor, channelId: string): Promise; recordActivity(actor: AgentActor, channelId: string, activity: ChannelActivity): Promise<...>; }' is missing the following properties from type 'ChannelStore': direct, signalBusy, signalChannelBusy +server/tests/routine-runner.test.ts:40: error TS2741: Property 'lastMessageAt' is missing in type '{ id: string; name: string; agentIds: string[]; threadId: string; active: true; }' but required in type 'AgentChannel'. +server/tests/routine-runner.test.ts:79: error TS2740: Type '{ create: () => never; listFor: () => never; update: () => never; remove: () => never; dueRoutines: () => never; advanceNextRun: () => never; insertRun: () => never; runContext(runId: string): Promise; finishRun(runId: string, status: RoutineRunOutcome, error: string | undefined): Promise<....' is missing the following properties from type 'RoutineStore': startManualRun, listRuns, routineForFiring, reapAbandonedRuns, and 3 more. server/tests/routing-routes.test.ts:106: error TS2345: Argument of type '(input: { id: string; name: string; roleDescription: string; visibility: AgentVisibility; }) => AgentProfile' is not assignable to parameter of type '(value: { id: string; name: string; roleDescription: string; visibility: string; }, index: number, array: { id: string; name: string; roleDescription: string; visibility: string; }[]) => AgentProfile'. server/tests/routing-service.test.ts:500: error TS2339: Property 'names' does not exist on type 'CoworkerRouteResult'. server/tests/routing-service.test.ts:501: error TS2339: Property 'names' does not exist on type 'CoworkerRouteResult'. From c870ccd4cc653cb300e8c9ae84a8cc6626357e41 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 16:55:40 +0200 Subject: [PATCH 216/232] rehome(2/2): arcade routes into main's route-group layout Ports `git diff bb62c653 a54ba3a3 -- server/src/plugins/routes.ts` (+1,569/-65) onto the route-groups split from #784. A port, not a fix: no behaviour changes. Method: the diff was split into 40 hunks; 34 matched exactly one route-group module by pre-image and were applied in place. The other 6 (imports, module helpers, the activeProvider/activeVendor/appOfUrl closures, and the final hunk carrying the new routes) were placed by hand. A line check finds every added line in the new modules (13 differ only by import path or an `export` prefix) and no removed line surviving. - context.ts: logReturnUrlRefusal, directoryUnavailable (replaces DIRECTORY_UNAVAILABLE), signInUrlFor, AUTHORIZATION_REFUSALS, sameAccount, VendorTimeoutError, the deadline constants, withVendorDeadline (executor shape kept verbatim), settledStatus; PluginRouteContext gains activeProvider, activeVendor, appOfUrl. - routes.ts: builds those three (with the construction-time throw) and re-exports AUTHORIZATION_REFUSALS / AuthorizationRefusal. - servers.ts: `/` gains authorizesPerAction + provider; remove's vendor sentences and guarded forgetRule; account-mode uses appOfUrl. - composio-apps.ts: deadline-wrapped listApps, directoryUnavailable, provider-named sentences, appOfUrl. - connections.ts: connect-path sentences and deadlines, brokeredAccountFor readOnly, GET /servers/:id/grants, and after disconnect: GET /servers/:id/connection/siblings, POST + DELETE /servers/:id/authorize, GET /arcade/verify. Guard entries re-keyed (path only): pending-authorization-audit-ledger, 7 CALL_SITES keys and 1 NON_WRITES file, routes.ts -> route-groups/connections.ts. Co-Authored-By: Claude Opus 5.5 --- .../src/plugins/route-groups/composio-apps.ts | 40 +- .../src/plugins/route-groups/connections.ts | 1199 ++++++++++++++++- server/src/plugins/route-groups/context.ts | 272 +++- server/src/plugins/route-groups/servers.ts | 79 +- server/src/plugins/routes.ts | 85 +- ...pending-authorization-audit-ledger.test.ts | 224 +-- 6 files changed, 1716 insertions(+), 183 deletions(-) diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts index 16a38c744..ca7c4ead9 100644 --- a/server/src/plugins/route-groups/composio-apps.ts +++ b/server/src/plugins/route-groups/composio-apps.ts @@ -1,11 +1,12 @@ import { requireAdmin } from "../../auth/guards"; import { reasonWithoutStatement } from "../../db/query-failure"; +import { BROKERED_PROFILE } from "../access"; import { type BrokerApp, BrokerUnconfiguredError, brokerSentence, } from "../broker"; -import { toolkitOf, vendorSentence } from "../composio"; +import { vendorSentence } from "../composio"; import { CatalogueEntryUnknownError, CustomServerRefusedError, @@ -15,8 +16,9 @@ import { } from "../store"; import { brokerRefusal, - DIRECTORY_UNAVAILABLE, + directoryUnavailable, type PluginRouteContext, + withVendorDeadline, } from "./context"; /** Browsing Composio's directory and enabling an app out of it. */ @@ -25,6 +27,9 @@ export function mountComposioAppRoutes({ store, requireUser, composio, + activeProvider, + activeVendor, + appOfUrl, actorEmail, }: PluginRouteContext) { /** @@ -44,8 +49,9 @@ export function mountComposioAppRoutes({ * {@link BrokerUnconfiguredError}'s own message is what is sent rather than a sentence written * here. * - * `enabled` comes off `toolkitOf(url)` and never off the row's id. The url is where the - * transport reads which app a call is against, so it is the only reading that decides anything; + * `enabled` comes off `appOfUrl(url)` — the active broker's own parser — and never off the + * row's id. The url is where the transport reads which app a call is against, so it is the only + * reading that decides anything; * the id names the row — `composio-linear` — and reading one as the other would quietly work * until somebody renamed a row. `serverUrls` hands over the urls and nothing else, which is that * property made structural: there is no id here to read by mistake. @@ -62,7 +68,7 @@ export function mountComposioAppRoutes({ if (!composio) { return context.json( { - error: new BrokerUnconfiguredError(store.brokerProvider, "operator") + error: new BrokerUnconfiguredError(activeProvider, "operator") .message, }, 503, @@ -71,11 +77,14 @@ export function mountComposioAppRoutes({ let directory: BrokerApp[]; try { - directory = await composio.broker.listApps(); + directory = await withVendorDeadline(() => composio.broker.listApps()); } catch (error) { // The vendor's own sentence where there is one, because "invalid api key" is the diagnosis // and a 500 is not. See {@link brokerRefusal} for what is kept out of the answer. - const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + const refusal = brokerRefusal( + error, + directoryUnavailable(activeProvider), + ); return context.json({ error: refusal.error }, refusal.status); } /* @@ -124,7 +133,7 @@ export function mountComposioAppRoutes({ const enabled = new Set( urls - .map((url) => toolkitOf(url)) + .map((url) => appOfUrl(url)) .filter((toolkit): toolkit is string => toolkit !== null), ); return context.json({ @@ -152,7 +161,7 @@ export function mountComposioAppRoutes({ if (!composio) { return context.json( { - error: new BrokerUnconfiguredError(store.brokerProvider, "operator") + error: new BrokerUnconfiguredError(activeProvider, "operator") .message, }, 503, @@ -177,7 +186,7 @@ export function mountComposioAppRoutes({ let directory: BrokerApp[] = []; if (slug) { try { - directory = await composio.broker.listApps(); + directory = await withVendorDeadline(() => composio.broker.listApps()); } catch (error) { /* * The same failure the directory route answers, in the same words, because it is the same @@ -185,7 +194,10 @@ export function mountComposioAppRoutes({ * deployment whose key is wrong was told nothing at all, on the one screen where the key * had just been set. */ - const refusal = brokerRefusal(error, DIRECTORY_UNAVAILABLE); + const refusal = brokerRefusal( + error, + directoryUnavailable(activeProvider), + ); return context.json({ error: refusal.error }, refusal.status); } } @@ -194,7 +206,7 @@ export function mountComposioAppRoutes({ return context.json( { error: slug - ? `${slug} is not an app Composio lists for this deployment.` + ? `${slug} is not an app ${BROKERED_PROFILE[activeProvider].vendor} lists for this deployment.` : "An app is required.", }, 400, @@ -331,7 +343,7 @@ export function mountComposioAppRoutes({ JSON.stringify({ type: "composio-app-not-enabled", slug: app.slug, - note: "Enabling a brokered app failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence.", + note: `Enabling a brokered app failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence.`, error: reasonWithoutStatement(error), }), ); @@ -339,7 +351,7 @@ export function mountComposioAppRoutes({ const refusal = brokerRefusal( error, - `${app.name} could not be enabled, and Composio said nothing about why. Try again, and check this deployment's Composio key if it persists.`, + `${app.name} could not be enabled, and ${activeVendor} said nothing about why. Try again, and check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts index 2b09725fb..9812afc75 100644 --- a/server/src/plugins/route-groups/connections.ts +++ b/server/src/plugins/route-groups/connections.ts @@ -1,15 +1,18 @@ import type { AppVariables } from "../../auth/guards"; +import { reasonWithoutStatement } from "../../db/query-failure"; +import { normalizeArcadeError } from "../arcade-adapter"; import { type BrokerField, BrokerUnconfiguredError, brokerReturnUrl, + brokerSentence, type Decides, + type GrantState, isFieldScheme, type SchemeKind, schemeKind, } from "../broker"; import { catalogueEntry } from "../catalogue"; -import { toolkitOf } from "../composio"; import { authorizationUrlFor, type ConnectOrigin, @@ -19,13 +22,23 @@ import { redirectUriFor, sealConnectState, } from "../oauth"; -import type { AccountRef } from "../shared-accounts"; +import { type AccountRef, asAccountRef } from "../shared-accounts"; import { CatalogueEntryUnknownError, type OAuthClient, PluginRefusedError, } from "../store"; -import { brokerRefusal, type PluginRouteContext } from "./context"; +import { + type AuthorizationRefusal, + brokerRefusal, + logReturnUrlRefusal, + type PluginRouteContext, + STATUS_CALL_TIMEOUT_MS, + sameAccount, + settledStatus, + signInUrlFor, + withVendorDeadline, +} from "./context"; /** A person's connections: listing them, connecting an app (OAuth or brokered) and disconnecting. */ export function mountConnectionRoutes({ @@ -34,6 +47,9 @@ export function mountConnectionRoutes({ requireUser, connect, composio, + activeProvider, + activeVendor, + appOfUrl, actorEmail, }: PluginRouteContext) { /** @@ -135,7 +151,7 @@ export function mountConnectionRoutes({ * person. On a deployment with no `OPENBOT_PUBLIC_URL` it failed one step earlier still, * refusing for want of a setting that has no bearing on a flow it does not enter. * - * The app comes off the row's url via `toolkitOf` rather than off its id, for the reason the + * The app comes off the row's url via `appOfUrl` rather than off its id, for the reason the * directory route says: the url is where the transport reads which app a call is against, and * the id is a row name that happens to look similar. * @@ -146,7 +162,7 @@ export function mountConnectionRoutes({ * a missing row did when this was a `.find`. */ const row = await store.serverAddress(serverId); - const toolkit = row ? toolkitOf(row.url) : null; + const toolkit = row ? appOfUrl(row.url) : null; if (row && toolkit) { /* * AN APP THAT NEEDS NO CREDENTIAL IS ANSWERED HERE, BEFORE `brokeredAccountFor` IS EVER @@ -201,7 +217,7 @@ export function mountConnectionRoutes({ if (!composio) { return context.json( { - error: new BrokerUnconfiguredError(store.brokerProvider, "person") + error: new BrokerUnconfiguredError(activeProvider, "person") .message, }, 503, @@ -339,14 +355,16 @@ export function mountConnectionRoutes({ */ let published: BrokerField[]; try { - published = await composio.broker.connectionFields({ - toolkit, - authScheme, - }); + published = await withVendorDeadline(() => + composio.broker.connectionFields({ + toolkit, + authScheme, + }), + ); } catch (error) { const refusal = brokerRefusal( error, - `Composio would not say what ${row.title} asks for, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + `${activeVendor} would not say what ${row.title} asks for, and said nothing about why. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -376,7 +394,7 @@ export function mountConnectionRoutes({ if (published.length === 0) { return context.json( { - error: `${row.title} publishes no boxes to fill in, so there is nothing you could type that would reach it and no account was made. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, + error: `${row.title} publishes no boxes to fill in, so there is nothing you could type that would reach it and no account was made. An administrator has to look at how ${row.title} is set up at ${activeVendor}; removing it on the Plugins page and adding it again is what records the way ${activeVendor} connects it now.`, }, 400, ); @@ -454,7 +472,7 @@ export function mountConnectionRoutes({ const optional = published.map((field) => field.name).join(", "); return context.json( { - error: `${row.title} publishes no box it says has to be filled in — only ${optional} — so nothing you could type would be a credential it insists on, and no account was made. An app that genuinely needs none is one Composio publishes as needing no authentication, and ${row.title} is recorded here as an app whose secret you type. An administrator has to look at how ${row.title} is set up at Composio; removing it on the Plugins page and adding it again is what records the way Composio connects it now.`, + error: `${row.title} publishes no box it says has to be filled in — only ${optional} — so nothing you could type would be a credential it insists on, and no account was made. An app that genuinely needs none is one ${activeVendor} publishes as needing no authentication, and ${row.title} is recorded here as an app whose secret you type. An administrator has to look at how ${row.title} is set up at ${activeVendor}; removing it on the Plugins page and adding it again is what records the way ${activeVendor} connects it now.`, }, 400, ); @@ -520,7 +538,7 @@ export function mountConnectionRoutes({ */ return context.json( { - error: `That is not the form ${row.title} publishes, so nothing was sent to Composio. Press Connect again to draw it from what the app asks for now, and fill in the boxes it shows; each one holds text.`, + error: `That is not the form ${row.title} publishes, so nothing was sent to ${activeVendor}. Press Connect again to draw it from what the app asks for now, and fill in the boxes it shows; each one holds text.`, }, 400, ); @@ -568,7 +586,7 @@ export function mountConnectionRoutes({ if (missing.length > 0) { return context.json( { - error: `${row.title} cannot be connected without ${missing.join(", ")}, so nothing was sent to Composio. Fill in every box the form marks required — a box holding only spaces is an empty one — and press Connect again.`, + error: `${row.title} cannot be connected without ${missing.join(", ")}, so nothing was sent to ${activeVendor}. Fill in every box the form marks required — a box holding only spaces is an empty one — and press Connect again.`, }, 400, ); @@ -609,7 +627,7 @@ export function mountConnectionRoutes({ } const refusal = brokerRefusal( error, - `${row.title} could not be connected with what you entered, and Composio said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + `${row.title} could not be connected with what you entered, and ${activeVendor} said nothing about why. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -636,7 +654,7 @@ export function mountConnectionRoutes({ if (kind === "unreadable") { return context.json( { - error: `This deployment cannot tell how ${row.title} connects, so nothing was sent to Composio and no account was made. An administrator has to remove it on the Plugins page and add it again, which records the way Composio connects it now.`, + error: `This deployment cannot tell how ${row.title} connects, so nothing was sent to ${activeVendor} and no account was made. An administrator has to remove it on the Plugins page and add it again, which records the way ${activeVendor} connects it now.`, }, 400, ); @@ -670,8 +688,7 @@ export function mountConnectionRoutes({ if (!connect?.appUrl) { return context.json( { - error: - "This deployment has no app URL configured, so Composio would have nowhere to send you back to. Set OPENBOT_APP_URL.", + error: `This deployment has no app URL configured, so ${activeVendor} would have nowhere to send you back to. Set OPENBOT_APP_URL.`, }, 503, ); @@ -715,27 +732,35 @@ export function mountConnectionRoutes({ */ let redirectUrl: string; try { - ({ redirectUrl } = await composio.broker.authorize({ - account, - toolkit, - /* - * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. - * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` - * entry — and nothing between there and Composio has ever looked at it, so - * `openbot.example.com` with the scheme left off builds a callback that is not a - * callback. That failure lands after somebody has consented, on the vendor's page, where - * this deployment cannot tell them anything; the guard moves it to before the link is - * minted, where the sentence reaches an operator who can set the variable. - */ - returnUrl: brokerReturnUrl( - connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), - store.brokerProvider, - ), - })); + ({ redirectUrl } = await withVendorDeadline(() => + composio.broker.authorize({ + account, + toolkit, + /* + * THE REFUSAL ABOVE CHECKS THAT A SETTING IS SET; THIS CHECKS THAT IT IS AN ADDRESS. + * `appUrl` is an environment string — `OPENBOT_APP_URL`, or the first `TRUSTED_ORIGINS` + * entry — and nothing between there and Composio has ever looked at it, so + * `openbot.example.com` with the scheme left off builds a callback that is not a + * callback. That failure lands after somebody has consented, on the vendor's page, where + * this deployment cannot tell them anything; the guard moves it to before the link is + * minted, where nobody has spent a consent on it yet. + * + * WHO IS READING DECIDES WHAT IS SAID, AND HERE THAT IS A PERSON. This route is + * `requireUser`: whoever pressed Connect gets the refusal's own sentence, which names no + * vendor and no variable because they can act on neither. The operator's half of it is + * logged below instead, where an operator looks and a person does not. + */ + returnUrl: brokerReturnUrl( + connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + activeProvider, + ), + }), + )); } catch (error) { + logReturnUrlRefusal(error, serverId); const refusal = brokerRefusal( error, - `Composio would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's Composio key if it persists.`, + `${activeVendor} would not begin a connection to ${row.title}, and said nothing about why. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -855,7 +880,7 @@ export function mountConnectionRoutes({ * * A ROW THAT IS NOT BROKERED IS REFUSED IN SO MANY WORDS. The id may well name a server this * deployment really has — what is wrong is that its connection does not live at Composio, and - * there is nothing for either route to confirm or to end. `null` from `toolkitOf` also covers an + * there is nothing for either route to confirm or to end. `null` from `appOfUrl` also covers an * id naming no row at all, which is the same answer from the caller's side. * * NO BROKER IS A 503 NAMING THE SETTING, as it is on the directory and on connect, and it is @@ -868,7 +893,7 @@ export function mountConnectionRoutes({ * actor who is not an administrator is refused here, once, instead of in four routes below. * * THE CONNECT ROUTE'S BROKERED BRANCH IS THE FOURTH, CALLED ONLY ONCE IT ALREADY KNOWS THE - * BRANCH IS TAKEN. That route still asks `toolkitOf` itself before this function is ever reached + * BRANCH IS TAKEN. That route still asks `appOfUrl` itself before this function is ever reached * — a row that is not brokered has an OAuth flow below it to fall through to, and refusing it * here, where there is no such flow to fall through to, would be wrong. Once that question is * settled and the answer is yes, whose account the connection is for is exactly the question @@ -878,6 +903,13 @@ export function mountConnectionRoutes({ const brokeredAccountFor = async ( context: { var: AppVariables }, serverId: string, + /** + * `readOnly` is true for a question about the account's STATE, which anybody working with the + * app benefits from, and false for every act done in it. It lifts exactly one refusal — the + * administrator requirement on a Shared app's account — and nothing else: the account + * resolved is the same one either way, so a reader is told about the identity calls use. + */ + options: { readOnly?: boolean } = {}, ): Promise< | { toolkit: string; @@ -893,7 +925,7 @@ export function mountConnectionRoutes({ } > => { const row = await store.serverAddress(serverId); - const toolkit = row ? toolkitOf(row.url) : null; + const toolkit = row ? appOfUrl(row.url) : null; if (!toolkit) { return { refusal: { @@ -905,8 +937,7 @@ export function mountConnectionRoutes({ if (!composio) { return { refusal: { - error: new BrokerUnconfiguredError(store.brokerProvider, "person") - .message, + error: new BrokerUnconfiguredError(activeProvider, "person").message, status: 503, }, }; @@ -916,6 +947,7 @@ export function mountConnectionRoutes({ return { refusal: { error: resolved.refusal, status: 400 } }; } if ( + !options.readOnly && resolved.ref.holder === "deployment" && context.var.actor.role !== "admin" ) { @@ -929,6 +961,69 @@ export function mountConnectionRoutes({ return { toolkit, account: resolved.ref, title: resolved.title }; }; + /** + * What the account behind this app holds, action by action: `{ actions: GrantState[] }`. + * + * A BOOLEAN WOULD NOT DO. An action can be blocked by consent, which Authorize moves, or by a + * deployment secret an operator sets once for everybody, which no amount of consenting moves; and + * a listing nobody could read is `unknown` rather than a pessimistic `needs-consent`, because + * telling somebody to authorize what they already authorized sends them round twice. The states + * are the broker's, passed through as it answered them. + * + * WHOSE IDENTITY IS ASKED ABOUT COMES FROM `brokeredAccountFor`: a Personal app's is the + * viewer's, a Shared app's is the app's own `shared_vendor_user_id`. Asking about the viewer for + * a Shared app would report a state no call ever uses. + * + * BEHIND `requireUser` AND NOT ADMIN-GATED: the status is a fact about the team's account that + * anybody working with the app benefits from. Minting is the privileged act, and the authorize + * route still refuses it with 403 through the same resolver. + * + * NO PRESENCE TEST ON THE BROKER AND NO REFUSAL PATH: every broker answers `grantsFor`, a + * degenerate answer being a real one, so this works on both deployments. A throw from the broker + * is answered as `unknown` carrying our own sentence, never the vendor's words, which could + * carry a credential. + */ + routes.get("/servers/:id/grants", requireUser, async (context) => { + const resolved = await brokeredAccountFor( + context, + context.req.param("id"), + { readOnly: true }, + ); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + const { toolkit, account } = resolved; + // `composio` is non-null here: the resolver refuses 503 before it returns an account. + const broker = composio?.broker; + if (!broker) { + return context.json( + { + error: new BrokerUnconfiguredError(activeProvider, "person").message, + }, + 503, + ); + } + let actions: GrantState[]; + try { + actions = await withVendorDeadline(() => + broker.grantsFor({ toolkit, account }), + ); + } catch { + actions = [ + { + action: toolkit, + state: "unknown", + reason: + "This deployment could not read what this account holds just now, so nothing is said about it either way. Try again in a moment.", + }, + ]; + } + return context.json({ actions }); + }); + /** * Ask Composio whether this person's account is really attached, and write the answer down. * @@ -1001,7 +1096,7 @@ export function mountConnectionRoutes({ */ const refusal = brokerRefusal( error, - "Composio would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's Composio key if it persists.", + `${activeVendor} would not say whether this account is connected, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Try again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -1089,7 +1184,7 @@ export function mountConnectionRoutes({ */ const refusal = brokerRefusal( error, - "Composio would not say whether this connection still works, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Press Re-check again, and ask an administrator to check this deployment's Composio key if it persists.", + `${activeVendor} would not say whether this connection still works, and gave no reason, so what is shown here is the last answer it gave rather than a fresh one. Press Re-check again, and ask an administrator to check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } @@ -1156,9 +1251,1023 @@ export function mountConnectionRoutes({ */ const refusal = brokerRefusal( error, - "Composio would not end this account, and gave no reason. Press Disconnect again: the revoke at Composio runs before anything here is deleted, so repeating it is safe and is the whole recovery. Ask an administrator to check this deployment's Composio key if it persists.", + `${activeVendor} would not end this account, and gave no reason. Press Disconnect again: the revoke at ${activeVendor} runs before anything here is deleted, so repeating it is safe and is the whole recovery. Ask an administrator to check this deployment's ${activeVendor} key if it persists.`, + ); + return context.json({ error: refusal.error }, refusal.status); + } + }); + + /** + * WHAT DISCONNECTING THIS APP WOULD END BESIDES IT, for the confirmation to show before the + * press. Answers `{ siblings, unanswered }`: the other apps' toolkits, sorted, and how many apps + * on the same vendor identity could not be asked about and so are left out (a count, never + * names). It acts on nothing: no revoke, no delete, no audit row. + * + * THE SET IS THE STORE'S, KEYED ON THE PAIR `(vendor identity, auth provider)` AND NEVER ON THE + * PROVIDER ALONE — see `siblingApps`. Nothing is derived here, so this route and the cascade + * `DELETE /servers/:id/connection` runs cannot disagree about which apps are named. On Composio + * an app is its own auth provider, so the answer is always empty. + * + * THE ACCOUNT COMES FROM `brokeredAccountFor`, exactly as on the DELETE it previews: the list is + * about the account that press would end, so it is asked of the same resolution and is refused + * to the same callers. + */ + routes.get( + "/servers/:id/connection/siblings", + requireUser, + async (context) => { + const resolved = await brokeredAccountFor( + context, + context.req.param("id"), + ); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + try { + return context.json( + await store.siblingApps({ + toolkit: resolved.toolkit, + account: resolved.account, + }), + ); + } catch (error) { + const refusal = brokerRefusal( + error, + "This deployment could not work out which other apps share this connection, and the vendor said nothing about why. Try again.", + ); + return context.json({ error: refusal.error }, refusal.status); + } + }, + ); + + /** + * Begin authorizing ONE ACTION of a brokered app, for a provider that grants action by action. + * Answers `{ authorizationUrl }` and nothing else. + * + * ORDERING PRINCIPLE: EVERYTHING THAT CAN FAIL WITHOUT THE VENDOR RUNS BEFORE THE CLAIM; THE + * CLAIM IS THE LAST THING BEFORE THE MINT. A bad `OPENBOT_APP_URL` must not leave a live attempt + * with no mint behind it, and the mint runs after the insert so a mint can never exist without a + * row to correlate it. + * + * THE CAPABILITY GATE IS FIRST, BEFORE `brokeredAccountFor` AND BEFORE ANYTHING IS WRITTEN. A + * broker that declares no authorization flow cannot complete one, so claiming a row for it would + * block the actor for the full attempt lifetime over a flow that was never possible; a gate after + * the claim does not prevent that. It is a presence test on a declared capability and never a + * vendor name. 503 and not 404 or 400: the route exists, so 404 would lie, and the request is not + * what is wrong; 503 is this file's vocabulary for a capability this deployment does not have. + * + * `actorId` IS THE SESSION SUBJECT (`users.id`) AND NEVER A CREDENTIAL ACTOR: the verifier finds + * the attempt from the returning browser's session. + * + * THE URL IS A BEARER CAPABILITY — answered to this browser and to nothing else. It is never + * logged, audited or put in an error body. No `next_uri` is sent: the vendor refuses every value, + * and the landing page comes from the attempt row instead. + * + * A FAILED MINT'S ATTEMPT is handled at the mint's `catch`. NOT HERE: cancelling one. + */ + routes.post("/servers/:id/authorize", requireUser, async (context) => { + const serverId = context.req.param("id"); + + if (!composio) { + return context.json( + { + error: new BrokerUnconfiguredError(activeProvider, "person").message, + }, + 503, + ); + } + const flow = composio.broker.authorizations; + if (!flow) { + return context.json( + { + error: + "This deployment's apps are connected once for the whole app, with the Connect button on the app's page; they are not authorized one action at a time.", + }, + 503, + ); + } + + const resolved = await brokeredAccountFor(context, serverId); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + const { account, toolkit, title } = resolved; + + const body = await context.req.json().catch(() => null); + const action = + body && typeof body === "object" && "action" in body + ? (body as { action: unknown }).action + : undefined; + // Exact `===` against the stored names: Arcade's are case-sensitive and never normalized. + if ( + typeof action !== "string" || + !(await store.serverPublishesAction(serverId, action)) + ) { + return context.json( + { error: `${title} does not publish an action by that name.` }, + 400, + ); + } + + if (!connect?.appUrl) { + return context.json( + { + error: + "This deployment has no app URL configured, so there is nowhere to send you back to. Set OPENBOT_APP_URL.", + }, + 503, + ); + } + const returnTo: ConnectOrigin = + context.req.query("returnTo") === "admin" ? "admin" : "settings"; + let returnUrl: string; + try { + returnUrl = brokerReturnUrl( + connectedAccountsUrlFor(connect.appUrl, { serverId }, returnTo), + activeProvider, + ); + } catch (error) { + logReturnUrlRefusal(error, serverId); + const refusal = brokerRefusal( + error, + "This deployment could not build the address to send you back to, so nothing was started.", ); return context.json({ error: refusal.error }, refusal.status); } + + const claim = await store.claimAuthorizationAttempt({ + actorId: context.var.actor.id, + serverId, + action, + account, + }); + if (!claim.claimed) { + return context.json( + { + error: claim.live + ? `An authorization for ${claim.live.action} is already in progress. Finish it, or wait for it to expire, before starting another.` + : "An authorization is already in progress. Finish it, or wait for it to expire, before starting another.", + }, + 409, + ); + } + + let minted: Awaited>; + try { + minted = await withVendorDeadline(() => + composio.broker.authorize({ + account, + toolkit, + action, + returnUrl, + }), + ); + } catch (error) { + /* + * WHAT A FAILED MINT DOES TO ITS ATTEMPT. THREE OUTCOMES, KEYED ON WHAT WAS ANSWERED. + * + * DEFINITE: the broker refused before asking (an authored `BrokerRefusalError`), or the + * vendor ANSWERED with an HTTP status. Nothing is live upstream, so the attempt is discarded + * and the actor is free to try again. Matched on `(actor_id, created_at)`, never `auth_id`: an + * attempt whose mint never completed has none, and `created_at` is what stops a late cleanup + * from deleting a NEWER attempt by the same actor. + * + * UNCERTAIN, AND THE ATTEMPT IS LEFT STANDING: `status === undefined` on a normalized SDK + * error. `APIConnectionError` and `APIUserAbortError` EXTEND `APIError`, so the normalizer + * answers `{ status: undefined, ... }` for them, NOT `null`; a dropped connection or an + * aborted socket means Arcade may well have created a flow. Keying this on `null` would class + * a timeout as a refusal and delete an attempt whose flow may be live, which frees the actor + * to start a second flow beside it. An attempt holding a null `auth_id` can never complete, + * so leaving it to expire is safe, and it is the point. + * + * THE NORMALIZER ANSWERING `null` IS UNCERTAIN TOO. That is "not an SDK error at all", and + * the adapter throws exactly such an `Error` ("Arcade answered an authorization with no id.") + * for a mint that answered SUCCESSFULLY without an `ar_...`: Arcade very likely did create a + * flow. Do not tidy this case into the definite branch. + * + * AND {@link VendorTimeoutError} LANDS IN THAT SAME UNCERTAIN BRANCH, which is why the mint's + * deadline is safe to add: it is neither authored nor an SDK error, so it is read as "we + * stopped waiting and do not know", the attempt is left standing, and a flow the vendor may + * have created behind the deadline is not orphaned beside a second one. + * + * NOTHING FROM THE VENDOR'S ANSWER IS LOGGED, AUDITED OR RETURNED: its body can carry the + * authorization url, a bearer capability. + */ + const authored = brokerSentence(error) !== null; + const failure = normalizeArcadeError(error); + const definite = + authored || (failure !== null && failure.status !== undefined); + if (definite) { + /* + * THE CLEAN-UP IS GUARDED, FOR THE SAME REASON THE RECORD-FAILURE CLEAN-UP BELOW IS. + * + * This discard is a tidy-up for a refusal the handler has ALREADY WORKED OUT — which vendor + * sentence to pass on, and whether it is a 502 or an authored 503. Unguarded, a fault on the + * delete threw past the handler and the person got a BODYLESS 500 instead: the least + * informative answer possible, in exactly the case where this route had something specific + * and true to tell them, and the failure of a clean-up destroyed the error it was cleaning + * up after. The refusal is the answer; whether the row went with it is not the person's + * problem to be given a blank page over. + * + * WHAT A FAILED DISCARD COSTS THEM IS SAID RATHER THAN GUESSED AT. The attempt stands with a + * null `auth_id`, so it can never complete and `claimAuthorizationAttempt` refuses a second + * start until it is fifteen minutes old — which makes the generic sentence's "try again in a + * moment" false. The extra clause is the same one the record-failure branch answers with for + * the same condition, and it is added rather than substituted: the vendor's own sentence is + * the half that says WHY, and it is not thrown away to make room for ours. + * + * The error is read through {@link reasonWithoutStatement}, as every database failure logged + * on this route is: a `DrizzleQueryError`'s own message is the statement and every value + * bound to it. + */ + let freed = false; + try { + freed = await store.discardAuthorizationAttempt({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + }); + } catch (cleanup) { + console.error( + JSON.stringify({ + type: "arcade-refused-attempt-not-cleared", + server: serverId, + action, + note: "A mint was refused and its attempt could not be deleted. The person was told why the vendor refused, and the row holds them until it expires.", + error: reasonWithoutStatement(cleanup), + }), + ); + freed = false; + } + const refusal = brokerRefusal( + error, + "The app's vendor refused to start that authorization, so nothing was started. Try again in a moment.", + ); + return context.json( + { + error: freed + ? refusal.error + : `${refusal.error} The attempt could not be cleared either; it expires within fifteen minutes, and starting over works after that.`, + }, + refusal.status, + ); + } + return context.json( + { + error: + "The app's vendor did not answer clearly, and the authorization may or may not have started. Wait for it to expire, or finish it if you were sent to a consent screen, before starting another.", + }, + 502, + ); + } + // A null id is recorded as NULL: an attempt holding none can never complete and simply expires. + try { + await store.recordAuthorizationMint({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + authId: minted.authorizationId, + }); + } catch (error) { + /* + * THE ONE WRITE ON THIS ROUTE THAT RUNS AFTER SOMETHING IRREVERSIBLE, AND IT USED TO BE THE + * ONLY CALL HERE WITH NO GUARD AT ALL. + * + * The mint SUCCEEDED: a flow is live at the vendor. Unhandled, a transient fault on this one + * local UPDATE — a statement timeout, a pool exhausted, a failover — threw past the handler + * into a BODYLESS 500, and left the claim standing with a NULL `auth_id`. That row is the + * worst of both halves. It can never complete, because the return leg matches the attempt on + * `auth_id` and a null matches no `flow_id` the browser can bring back; and it still HOLDS + * THE ACTOR, because `claimAuthorizationAttempt` refuses a second start until the row is + * fifteen minutes old. So one local hiccup turned a successful authorization into a person + * locked out of retrying, told nothing, with a live flow at Arcade that this deployment has + * no record of. + * + * THE ATTEMPT IS DISCARDED RATHER THAN LEFT TO EXPIRE, and this is NOT the uncertain-mint + * branch above turned around. There, the row is left standing because it may yet be the + * record of a live flow and deleting it would free the actor to run a second flow beside one + * that could still complete. Here the row provably records NOTHING: the id that would let it + * complete is exactly the value that failed to be written. Keeping it buys no safety and + * costs the person a quarter of an hour of refusals. Matched on `(actor_id, created_at)`, so + * a slow arrival here can never delete a newer attempt, and wrapped in its own `try` because + * the fault that broke the write above is likely to break this one too — a failed cleanup + * must not replace this refusal with the bodyless 500 it exists to prevent. + * + * AND THE MINT IS NOT LOST. The console line is what is left to carry it once the database + * has refused the row, and it holds the `ar_...` the vendor answered — the plain-text value + * this route was about to store — so an operator can find that flow at the vendor and end it. + * THE REDIRECT URL IS NOT LOGGED AND NEVER WILL BE: that is the bearer capability, and + * whoever opens it attaches the account. The error goes through + * {@link reasonWithoutStatement} for the reason `oauth-connection-not-recorded` gives: a + * `DrizzleQueryError`'s own message is the statement and every value bound to it. + */ + let freed = false; + try { + freed = await store.discardAuthorizationAttempt({ + actorId: context.var.actor.id, + createdAt: claim.attempt.createdAt, + }); + } catch { + freed = false; + } + console.error( + JSON.stringify({ + type: "arcade-mint-not-recorded", + server: serverId, + action, + authorizationId: minted.authorizationId, + attemptCleared: freed, + note: "An authorization was minted at the vendor and could not be written to its attempt. The person was told, was not sent to the consent screen, and the flow named here is live at the vendor with nothing here recording it.", + error: reasonWithoutStatement(error), + }), + ); + return context.json( + { + error: freed + ? "The app's vendor started that authorization, and this deployment could not record it — so it cannot be finished here and nothing was connected. Press Authorize again: the attempt was cleared, so starting over is safe." + : "The app's vendor started that authorization, and this deployment could not record it — so it cannot be finished here and nothing was connected. The attempt could not be cleared either; it expires within fifteen minutes, and starting over works after that.", + }, + 500, + ); + } + return context.json({ authorizationUrl: minted.redirectUrl }); + }); + + /** + * Cancel THIS actor's authorization attempt, so somebody who closed the consent tab need not wait + * out the fifteen-minute TTL. Answers `{ cancelled }`: whether an attempt was removed. + * + * CANCELLING WHEN NO ATTEMPT EXISTS IS NOT AN ERROR — the attempt may have expired, failed or + * been cancelled already, and the person's intent (nothing in flight, nothing connected) is met. + * + * A CONSENT THAT COMPLETED UNDER THE CANCEL IS THE ONE CASE WHERE THAT INTENT IS NOT MET, AND IT + * IS ANSWERED 409 RATHER THAN `{ cancelled: true }` OR `{ cancelled: false }`. The attempt is read + * and then discarded, and a completion can commit between the two. `discardAuthorizationAttempt` + * refuses a settled row, so the record of that consent survives; but the account IS connected + * now, and neither boolean says so — `true` would be a lie and `false` reads as "there was nothing + * to cancel". The person asked for nothing to be connected and something was, so they are told + * which, in an error a client cannot mistake for success, and pointed at the one thing that undoes + * it: disconnecting. Any other settled outcome (failed, unreconciled, withdrawn) connected nothing, + * so it is the ordinary `{ cancelled: false }`. + * + * MATCHED ON `(actor_id, created_at)`, NEVER ON `auth_id`: an attempt whose mint never completed + * has no `auth_id` and is exactly the one most worth cancelling, and `created_at` is what stops + * this from deleting a NEWER attempt by the same actor that replaced the one resolved here. It + * goes through `discardAuthorizationAttempt`, the same deletion the mint's definite-failure + * discard uses. + * + * `actorId` IS THE SESSION SUBJECT (`users.id`), NEVER A CREDENTIAL ACTOR. BEHIND `requireUser` + * AND NOT ADMIN-GATED AT THE ROUTE, as the disconnect route above: `brokeredAccountFor` is where + * a Shared app is refused to anybody but an administrator. + * + * THE ATTEMPT IS RESOLVED BEFORE THE APP IS, AND THAT ORDER IS THE WHOLE POINT. Cancelling is an + * operation on the ATTEMPT ROW, and this route reads NOTHING off `brokeredAccountFor`'s success + * branch — not the toolkit, not the account, not the title. The deletion is keyed + * `(actor_id, created_at)` off the row itself. So resolving the app first bought no fact this + * route uses and cost the one case that needs cancelling most: AN ATTEMPT OUTLIVES ITS APP. Once + * an administrator removes the row, `brokeredAccountFor` answers `appOfUrl(null)` → "That app is + * not reached through a broker", 400, and a person holding a live pending authorization on an app + * that is gone could never retract it — while `claimAuthorizationAttempt` went on refusing them a + * new one for the full fifteen minutes. A not-found about the app, in answer to a request about + * their own attempt. + * + * DROPPING THE GATE FOR THAT CASE GIVES NOBODY ANYTHING THEY DID NOT ALREADY HAVE. The row + * reached here is the signed-in person's OWN attempt, by the only key the table has, and the act + * is its deletion; the administrator requirement on a Shared app guards that app's deployment + * account, which this route never touches. The one actor it newly admits is somebody whose + * authority changed while their own consent was in flight — and letting them retract what they + * started is the right answer, not a hole. + * + * THE REFUSALS SURVIVE WHERE THEY STILL MEAN SOMETHING, which is the branch with nothing of this + * actor's to cancel. A DELETE aimed at an ordinary OAuth row, or at no row at all, is then still + * told so in the words every sibling route uses, rather than being flattened into a cheerful + * `{ cancelled: false }` that claims the request was understood. + * + * AND IT CANCELS THE ATTEMPT THE PATH NAMES, NEVER MERELY "WHATEVER THIS ACTOR IS HOLDING". The + * attempt key is the actor alone, so the lookup can only answer that one row — but the request + * names an app, and an attempt standing on a DIFFERENT app is not the thing being cancelled. + * Deleting it anyway killed an in-flight consent begun from another screen and answered + * `{ cancelled: true }` as though it had done what was asked, which is the same broken + * correlation the mint/claim/complete path exists to hold from the other end. A standing attempt + * on another app is therefore the `{ cancelled: false }` of "no attempt for THIS app", which is + * the same answer, and the same non-error, as there being no attempt at all. + * + * THE SERVER IS COMPARED AGAINST THE RAW PATH PARAMETER, which is exactly what + * `POST /servers/:id/authorize` writes into `server_id` when it claims the row. Comparing against + * anything derived — the toolkit, the resolved title — would be two spellings of one fact and a + * place for them to drift apart. + */ + routes.delete("/servers/:id/authorize", requireUser, async (context) => { + const serverId = context.req.param("id"); + const actorId = context.var.actor.id; + const attempt = await store.authorizationAttemptFor(actorId); + if (attempt && attempt.serverId === serverId) { + const cancelled = await store.discardAuthorizationAttempt({ + actorId, + createdAt: attempt.createdAt, + }); + /* + * A RETRACTION IS ON THE TRAIL, AND ONLY ONE THAT HAPPENED. The person withdrawing their own + * pending consent is the other deliberate ending of this lifecycle besides completing it, and + * it is filed for the same reason `mcp.account_connected` is. Only when the deletion removed + * a row: a cancel that lost a race to a newer attempt removed nothing and withdrew nothing. + * After the deletion and not around it, as the disconnect route files its row, so a failed + * audit write fails the request rather than passing silently. + */ + if (cancelled) { + await store.recordAuthorizationCancelled({ actorId, attempt }); + } else if ( + /* + * AND A CANCEL THAT LOST TO THE COMPLETION SAYS SO. The discard refuses a settled attempt, + * so `cancelled` is false here for two different reasons; only one of them is "nothing was + * standing". Answering `{ cancelled: false }` to a person whose consent completed would + * leave them believing they had retracted an account that is now connected. + */ + (await store.settledAuthorizationOutcome({ + actorId, + createdAt: attempt.createdAt, + })) === "completed" + ) { + return context.json( + { + error: + "That authorization finished before it could be cancelled, so the account is connected. Disconnect it if you did not mean to connect it.", + }, + 409, + ); + } + return context.json({ cancelled }); + } + const resolved = await brokeredAccountFor(context, serverId); + if (resolved.refusal) { + return context.json( + { error: resolved.refusal.error }, + resolved.refusal.status, + ); + } + return context.json({ cancelled: false }); + }); + + /** + * Where a person's BROWSER lands after a brokered per-action consent: the verifier. + * + * THIS ROUTE BREAKS THIS FILE'S JSON/401 CONVENTION DELIBERATELY, AND THE REASON IS WRITTEN HERE + * SO NOBODY "FIXES" IT TO MATCH ITS NEIGHBOURS. Every other route in this file answers `fetch` + * from OpenBot's own pages, so JSON is what the caller can read and 401 is what an unauthenticated + * one deserves. Here the caller is a PERSON'S BROWSER, arriving on a top-level navigation from the + * broker's own screen with a just-completed consent behind it — so an unauthenticated arrival is + * sent to sign-in with a 303 and a `Location` CARRYING ITS `flow_id`, never answered 401. A 401 + * here is somebody who has just granted a third party access to their mailbox being shown a raw + * error object on an API origin that serves no pages. + * + * WHICH IS WHY `requireUser` IS RUN BY HAND RATHER THAN MOUNTED IN FRONT OF THE HANDLER. The guard + * answers 401 JSON, and the whole point of this route is that it must not. It is still the same + * guard — the session is read, the role is resolved and `actor` is set by it, so nothing here + * re-implements authentication — and only its REFUSAL is translated into the redirect. The + * composition is the one `admin/gate.ts` already uses: call it with a `next` that records that it + * ran. + * + * SIGNING BACK IN AS THE SAME PERSON THEN COMPLETES NORMALLY, because the attempt is keyed on the + * signed-in person. Signing in as somebody else finds no attempt and refuses, which is correct and + * occasionally annoying. + * + * CRITERION: NO REQUEST TO THIS ENDPOINT ENDS WITHOUT A `Location`, WITH ONE STATED EXCEPTION. + * That is the criterion `/oauth/callback` below already holds itself to in as many words, and this + * is the file's second browser-navigation return leg — so a refusal here ends the same way that + * one does: back at the connected-accounts LIST, with a word about what happened and nothing + * written. The reason travels as an opaque code on `?authorized=` + * ({@link AUTHORIZATION_REFUSALS}); the sentence belongs to the app, which is the only place it is + * written down. THE LIST AND NOT AN APP PAGE, uniformly: three of the refusals have no attempt row + * to build an app address from, and {@link connectedAccountsUrlFor} already argues that picking + * one would be a guess about what somebody had been doing. + * + * THE EXCEPTION IS THE 403, AND IT IS STATED RATHER THAN INHERITED. The guard that refuses a + * session with no role is the same guard `/api/me` answers from, so such a session cannot reliably + * load the landing page either — it is sent to `/sign` or into an error boundary, which is the + * dead end that branch exists to avoid. It keeps its JSON body. + */ + routes.get("/arcade/verify", async (context) => { + /** + * Where a refusal lands, which is the LIST screen with the reason beside it. + * + * Built from {@link connectedAccountsUrlFor}'s failure address with its query dropped, so the + * one place that knows how to address the app's origin stays the one place that knows — and so + * that `?connected=failed`, which belongs to the Composio callback, is NOT carried along beside + * `?authorized=` and drawn as a second notice about something that did not happen. + */ + const refusedWith = (reason: AuthorizationRefusal) => { + const [screen] = connectedAccountsUrlFor(connect?.appUrl, { + failed: true, + }).split("?"); + return context.redirect(`${screen}?authorized=${reason}`, 303); + }; + + /* + * Read, not yet judged. The redirect below has to carry it, and that redirect happens before + * the value is validated — reading a query string calls nobody and writes nothing. + */ + const flowId = (context.req.query("flow_id") ?? "").trim(); + + /** + * WHETHER THE COMPLETION TRANSACTION IS KNOWN TO HAVE COMMITTED, and the one thing the two + * `catch`es below have to agree about. Declared out here, ahead of the `try`, because the + * blanket `catch` has to be able to tell a throw over a live, recorded grant from a throw over + * nothing at all — which is the distinction it used to answer `not-recorded` for both halves + * of. Set in exactly one place: the completion's own `catch` below. + */ + let recorded = false; + + /* + * EVERYTHING FROM HERE IS IN ONE `try`, FOR THE REASON `/oauth/callback` BELOW GIVES FOR ITS + * OWN: so it is the whole body rather than each call. A blanket `catch` is a promise about the + * route; a per-call one is a promise about the calls somebody remembered. The criterion is + * that no request to this endpoint ends without a `Location`, and a call that throws would + * otherwise put somebody who has just consented at another company on a bodyless 500 — there + * is no `app.onError` on this app to turn that into anything else, and this is an origin that + * serves no pages. + * + * IT OPENS HERE AND NOT AT THE COMPLETION, which is where it used to open and where it was a + * promise about the calls somebody remembered. Two awaited reads happen first and both reach a + * store: `requireUser` reads the session and then this actor's roles, and + * `authorizationAttemptFor` is the one query on the happy path. A database blip or an + * exhausted pool during the attempt read is the ordinary way to reach either. + * + * THE STATED EXCEPTION IS UNTOUCHED BY THIS. The 403 for a session with no role RETURNS, and a + * `catch` sees throws and not returns, so it keeps its JSON body. + * + * The inner `try`s below take the vendor's throws first, so no refusal code changes here. AND + * THE COMPLETION NOW HAS ONE OF ITS OWN, which is not a hedge against this `catch` but the + * thing this `catch` cannot do: it is the only step on this leg that can leave a grant RECORDED + * on the way out, so it is the only one that can say whether `not-recorded` is true. See it. + */ + try { + let signedIn = false; + const refusal = await requireUser(context, async () => { + signedIn = true; + }); + if (!signedIn) { + /* + * ONLY A MISSING SESSION GOES TO SIGN-IN. The guard also refuses 403 for a session with no + * role, and sending that person to sign-in is a dead end: the sign-in screen's own load hits + * the same guard, gets the same 403, and they sign in as themselves again with nothing + * changed and never told their access was removed. + */ + if (refusal instanceof Response && refusal.status === 401) { + return context.redirect(signInUrlFor(connect?.appUrl, flowId), 303); + } + return context.json( + { + error: + "This deployment no longer recognises you, so nothing was recorded. Ask an administrator to restore your access, then start the authorization again from the app's page.", + }, + 403, + ); + } + + /* + * THE CAPABILITY GATE, AND IT IS A PRESENCE TEST ON A VALUE THE BROKER DECLARED. Never a + * `provider === "arcade"` comparison: see {@link ConnectedAppBroker.authorizations}, and + * `store.ts`'s gate comment, which records a defect where a gate and its exemption keyed on + * different things and the exemption failed OPEN. A deployment whose broker runs no correlated + * authorization flow has no return leg for this route to verify. + */ + if (!composio) return refusedWith("no-broker"); + const flow = composio.broker.authorizations; + if (!flow) return refusedWith("not-supported"); + + /* + * A HANDLE THIS DEPLOYMENT CANNOT USE IS REFUSED WITHOUT ASKING THE VENDOR ANYTHING. Empty, + * missing or whitespace-only are one fact — the browser arrived without the one thing the + * return leg carries — and spending a vendor call on it would be an unauthenticated-shaped + * request reaching another company's API. + */ + if (!flowId) return refusedWith("missing-handle"); + + /* + * THE ATTEMPT COMES FROM THE SIGNED-IN ACTOR AND NEVER FROM THE HANDLE. The vendor's return-leg + * id and the id its mint answered with are different id spaces with no endpoint between them, + * and nothing here may try to bridge them; trying identities until one is accepted would be an + * oracle reporting which of this deployment's identities have flows outstanding. The session + * names exactly one candidate, which is what makes the correlation exact rather than probable. + * + * {@link PluginStore.authorizationAttemptFor} already excludes a completed attempt and one past + * the fifteen-minute TTL, so all three absences arrive here as `null` and are refused in one + * sentence. A NULL `auth_id` is the fourth: its mint never completed, so there is nothing for + * the binding below to be checked against and the attempt can only ever expire. + * + * A ROW THAT CANNOT SAY WHOSE ACCOUNT IT IS FOR IS THE FIFTH, AND IT IS REFUSED HERE RATHER + * THAN READ AS THE DEPLOYMENT'S. `(holder, user_id)` is held by + * `pending_authorizations_holder_check`, so a `person` row with no user id cannot be written + * and this branch is unreachable through the table — which is exactly why it must be a + * refusal: the reading it replaces rebuilt that row as `{ holder: "deployment" }` and then + * aimed the withdrawal below at the DEPLOYMENT's account, ending a connection nobody asked + * about while the person's stayed live. Refused BEFORE the vendor is asked anything, like the + * null `auth_id` above and for the same reason: the attempt can never complete, so no call is + * worth spending on it, and nothing is written — the row stands until its TTL. + */ + const actorId = context.var.actor.id; + const attempt = await store.authorizationAttemptFor(actorId); + if (!attempt || attempt.authId === null) return refusedWith("no-attempt"); + const attemptAccount = asAccountRef(attempt); + if (!attemptAccount) return refusedWith("no-attempt"); + const authId = attempt.authId; + + /* + * THE BINDING. `handle` IS AN OPAQUE STRING THE SEAM DOES NOT INTERPRET — the vendor's word for + * it stops at the adapter, and nothing here parses it, compares it to a scheme or reads a + * provider out of it. + * + * THE MISMATCH BRANCH IS ASSUMPTION-BACKED AND IS MOCKED-ONLY UNTIL A LIVE PROBE RUNS. The SDK + * declares no error shape for a handle belonging to somebody else; the adapter detects one by + * normalizing the thrown error and reading `user_mismatch`, and RETHROWS anything else. If the + * vendor's real shape differs, `confirmReturn` does not quietly answer `{ mismatch: true }` — + * it throws, and the branch below surfaces an outage instead of a clean refusal. That is UX + * degradation and not a hole: nothing is written and the attempt row is left alone either way, + * and the `auth_id` comparison underneath independently covers a `confirm` that SUCCEEDS + * against the wrong identity. Do not read a passing mocked test as proof this path works. + */ + let confirmed: Awaited>; + try { + confirmed = await withVendorDeadline( + (signal) => + flow.confirmReturn( + { + handle: flowId, + vendorUserId: attempt.vendorUserId, + }, + { signal }, + ), + STATUS_CALL_TIMEOUT_MS, + ); + } catch { + /* + * NOTHING FROM THE VENDOR'S ANSWER TRAVELS WITH THIS — not its sentence, not its body, not + * the error. A reply on this leg can carry an authorization url, which is a bearer capability, + * and an outage is not worth a person's consent being quotable out of an error body. + */ + return refusedWith("vendor-unavailable"); + } + + /* + * TWO REFUSALS, ONE SENTENCE, AND THE ATTEMPT ROW IS LEFT UNTOUCHED BY BOTH. A handle the vendor + * says belongs to somebody else, and a confirmation naming a DIFFERENT authorization than the + * one this deployment minted, are both "this return leg is not for the attempt you are holding". + * + * THE `auth_id` COMPARISON IS NOT OPTIONAL AND IT IS NOT HOPEFUL: the confirmation's id was + * measured IDENTICAL to the mint's through a completed consent, so this is an exact equality + * over one id space. Identity alone is not correlation — an expired read attempt, a fresh send + * attempt under the same identity, and then the read's callback arriving late matches on + * identity at every step, and a check stopping there would attribute a read consent to a send + * request. Local expiry does not cancel a vendor flow, so late callbacks are ordinary. + * + * NOTHING IS WRITTEN AND NOTHING IS CLOSED. A late callback must never consume or ALTER the + * replacement it arrived behind, so the row is not deleted, not completed and not marked. + */ + if ("mismatch" in confirmed || confirmed.authorizationId !== authId) { + return refusedWith("not-your-authorization"); + } + + /* + * THE BOUNDED WAIT. `pending` is polled a few times over a few seconds and never with the SDK's + * unbounded `waitForCompletion`: a browser is held open on this request, and a vendor that has + * stopped answering must not hold it open forever. See {@link settledStatus}. + * + * NOTHING FROM THAT ANSWER TRAVELS ANYWHERE (D31). The vendor's own status reply carries a LIVE + * PROVIDER ACCESS TOKEN and the person's full profile; the seam narrows it to three fields + * inside the adapter, and the three are read here into a decision, a comparison and the scope + * list the completion writes. Not one of them is logged, attached as a `cause` or put in an + * error body, and the status object itself reaches none of those either. A status response + * LOOKS LIKE METADATA, which is how the throwaway harness that proved this flow came to write + * the token to a file on its first run. + */ + let settled: Awaited>; + try { + settled = await settledStatus(flow, authId); + } catch { + return refusedWith("vendor-unavailable"); + } + + /* + * WHAT EACH OUTCOME DOES TO THE ATTEMPT ROW, AND THE TIMEOUT IS THE ONE THAT LEAVES IT ALONE. + * `failed` and an identity that is not the attempt's are verdicts, so the row is consumed and + * marked. STILL `pending` AT THE BOUND IS NEITHER A SUCCESS NOR A FAILURE: the consent may be + * finished a second later, and a row closed here would mean a person who came back to the + * link found nothing to finish. It stands until its TTL. + */ + if (settled.status === "failed") { + await store.failAuthorizationAttempt({ actorId, authId }); + return refusedWith("authorization-failed"); + } + if (settled.status !== "completed") return refusedWith("not-finished"); + if (settled.userId !== attempt.vendorUserId) { + await store.failAuthorizationAttempt({ actorId, authId }); + return refusedWith("not-your-authorization"); + } + + /* + * FRESHNESS (D28): A CONSENT WHOSE CONTEXT HAS MOVED ON IS NOT COMPLETED INTO THE PRESENT. + * + * The app's CURRENT account, its mode, the active provider and this actor's current authority + * are all re-resolved through the same resolver the mint used, and all of them have to still + * be the attempt's. The window is one consent's TTL and it needs an administrator to switch an + * app's mode twice inside it — which is rare, and silent: writing it would create a live grant + * bound to a retired identity that nothing will ever call with, and on an app somebody + * disconnected in between it would quietly resurrect the account. + * + * AND THE GRANT MAY ALREADY BE LIVE AT THE VENDOR, because the person completed it there + * before this deployment looked. So the RETIRED identity's connection is withdrawn — keyed on + * that identity and on this app, which CANNOT TOUCH THE CURRENT ACCOUNT: a connection is keyed + * `(user_id, provider_id)` and the retired identity is not the current one. Where that + * withdrawal succeeds the attempt is marked `withdrawn`; where it fails, is not attempted or + * answers `false` it is kept as `unreconciled` rather than closed, because a grant + * we know about and could not end is worth a row somebody can act on. + */ + /* + * THE RETIRED IDENTITY IS THE ATTEMPT'S OWN, READ ONCE AT THE TOP AND NOT REBUILT HERE. A + * reference built a second time from the row's columns is a second chance to build it wrong, + * and the one this replaced defaulted a pair it did not recognise to the deployment's. + */ + const retired: AccountRef = attemptAccount; + const fresh = await brokeredAccountFor(context, attempt.serverId); + if (fresh.refusal || !sameAccount(fresh.account, retired)) { + const row = await store.serverAddress(attempt.serverId); + const retiredApp = row ? appOfUrl(row.url) : null; + let withdrawn = false; + /* + * WHAT THIS WITHDRAWAL ENDS BESIDES THE APP IT NAMES, RESOLVED BEFORE THE REVOKE GOES OUT. + * + * Where a connection is keyed `(identity, auth provider)` — Arcade — the one revoke below + * ends the whole connection and so every app on the retired identity that shares it. Their + * rows here would be left standing, which makes this deployment's own records claim live + * connections for apps the vendor has already disconnected: a person sees apps listed as + * connected that fail on first use, and nothing here knows to tell them otherwise. The set + * is the store's, keyed on the pair and never on the provider alone, and BEFORE rather than + * after, which is the ordering `disconnectBrokered` and `removeServer` both keep: once the + * revoke has run, a sibling's auth provider is a question asked of a connection that is + * already gone. On Composio an app is its own auth provider, so it is always empty and + * nothing below differs from what this refusal always did. + * + * AND A SET THIS COULD NOT RESOLVE STOPS THE REVOKE RATHER THAN WIDENING IT. The ask is a + * live vendor call per app on a request a browser is held open on, so it is bounded — on + * the WHOLE resolution rather than on each call inside it, because the store's method takes + * no signal, which is the bound that matters to the browser. A timeout or a refusal leaves + * `collateral` null and nothing is then asked of the vendor at all. Ending apps this + * deployment cannot then name is the failure this whole branch is about, and the + * alternative is not silence: the attempt is kept `unreconciled` below, which is a row an + * operator can act on over a grant that is merely still live. + */ + let collateral: { siblings: string[]; unanswered: number } | null = + null; + if (retiredApp) { + try { + collateral = await withVendorDeadline(() => + store.siblingApps({ toolkit: retiredApp, account: retired }), + ); + } catch { + // Nothing from the vendor's answer travels with this, here as everywhere on this leg. + collateral = null; + } + } + if (retiredApp && collateral) { + try { + /* + * THE BOOLEAN IS READ, NOT DROPPED. `false` means "nothing there" — on Arcade a + * toolkit whose auth provider could NOT be resolved now refuses rather than answering + * it, so an unattributable app arrives here as the throw below. Both land on the + * not-withdrawn branch, which is the safe reading over a possibly-live grant. + */ + withdrawn = await withVendorDeadline(() => + composio.broker.revoke({ + account: retired, + toolkit: retiredApp, + }), + ); + } catch (error) { + /* + * A THROW HERE IS NOT AN ANSWER OF `false`, AND IT IS NOT READ AS ONE. + * + * `false` is the broker saying "there was nothing to withdraw", off a listing it read + * whole. A throw says nothing of the kind: the seam deletes EVERY connection it found + * and only then throws if any delete failed, so "revoke failed for 1 of 3" arrives + * here with two of three already gone; and a deadline that fires is this deployment + * giving up on a call the vendor may finish a moment later. Either way the vendor side + * may already be withdrawn — the commit-then-throw shape `/arcade/verify`'s completion + * catch below was fixed for, one call earlier on the same leg. + * + * SO THE OUTCOME IS NEITHER CLAIM. `withdrawn` stays false, which is the conservative + * half: no local row is cleared and no collateral is put on the trail on the strength + * of a withdrawal nobody saw, because the alternative — clearing on a guess — is + * this deployment forgetting a grant that may still be live, and that is the + * irreversible mistake. The attempt is closed `unreconciled`, which is the existing + * verdict for "we do not know what happened" and is exactly true here. + * + * WHAT `unreconciled` DOES NOT COVER, AND THIS LINE DOES. The row holds an outcome and + * a timestamp and nothing else: no reason, nothing reads it back, and it reads the same + * for a vendor that said "nothing there" as for a call whose outcome is unknown. It + * cannot say the one thing an operator needs here — that the vendor may ALREADY have + * ended this connection, so the rows this deployment still holds for the app and for + * every sibling on the same connection may be claiming live connections that are gone, + * or that the grant is live and needs ending by hand. That is a disagreement between + * this deployment and the vendor that nobody would otherwise find out about, and + * leaving no trace of it was half of the defect. A third outcome on the row would need + * a schema change to say what this line says, and the line is where every sibling on + * this route already reports a failure it swallowed. + * + * WHAT IS IN THE LINE is what `arcade-completion-not-recorded` allows itself and the + * names of this deployment's own apps: the authorization id an operator finds the flow + * by, the app, the sibling apps whose rows may now be stale, and the reason through + * {@link reasonWithoutStatement}. Errors out of the broker seam carry this deployment's + * own sentence — `ArcadeFailureError` (`arcade.ts`) drops the vendor's prose by construction + * and {@link VendorTimeoutError} is ours — so nothing of the vendor's answer travels + * with this, here as everywhere on this leg. No account id, no vendor user id. + */ + withdrawn = false; + console.error( + JSON.stringify({ + type: "arcade-context-withdrawal-unknown", + server: attempt.serverId, + app: retiredApp, + action: attempt.action, + authorizationId: authId, + siblings: collateral.siblings, + note: "A consent completed at the vendor after this app's account changed, and withdrawing the retired identity's grant raised instead of answering. Whether the vendor withdrew it is unknown: the attempt was closed unreconciled and no row here was cleared, so either the grant is still live at the vendor or this deployment's rows for the app and the siblings named here claim connections the vendor has already ended. Check the connection at the vendor and disconnect the app here to settle it.", + error: reasonWithoutStatement(error), + }), + ); + } + } + /* + * AND THE ROWS FOR WHAT IT ENDED GO WITH IT, ONLY ON AN ANSWER OF TRUE. `false` is "nothing + * found, or we could not work out what to withdraw", and no local row may be cleared — nor + * any collateral claimed on the trail — on the strength of an answer nobody got. The trail + * rows this writes are what tell a person why an app they never touched stopped being + * connected here, and tell an operator that one withdrawal took several apps with it. + */ + if (withdrawn && retiredApp && collateral) { + await store.clearWithdrawnAccount({ + toolkit: retiredApp, + account: retired, + siblings: collateral.siblings, + unanswered: collateral.unanswered, + // The actor this attempt is keyed on, read once at the top like everything else here. + by: actorId, + reason: "context_changed", + }); + } + if (withdrawn) + await store.markAuthorizationWithdrawn({ actorId, authId }); + else await store.markAuthorizationUnreconciled({ actorId, authId }); + return refusedWith("context-changed"); + } + + /* + * ONE TRANSACTION, AND A REPLAY IS A NO-OP RATHER THAN AN ERROR. The attempt row is the + * serialization point: zero rows updated means somebody else completed this attempt first, and + * the loser is answered exactly as the winner is and writes nothing. See + * {@link PluginStore.completeAuthorizationAttempt}. + * + * `authProvider` IS ASKED OF THE BROKER AND NEVER DERIVED FROM THE TOOLKIT NAME. The two + * coincide only where an operator happened to name their provider configuration after the + * app, which {@link ConnectedAppBroker.authProviderOf} sets out at length — so a derivation + * would pass every test in this tree and be wrong about the deployment it shipped to. A + * vendor that does not say is recorded as not saying, and a vendor that cannot be reached for + * it costs nobody their completed consent: this is an audit label, and the grant is already + * live. It is never read back out of a status reply, which D31's narrowing makes + * structurally impossible anyway. + */ + const authProvider = await withVendorDeadline(() => + composio.broker.authProviderOf(fresh.toolkit), + ).catch(() => null); + + /* + * AND THE LANDING COMES FROM THE ATTEMPT ROW (D18), NEVER FROM `next_uri`. The vendor refuses + * every `next_uri` at mint, and what `confirm_user` answers is the vendor's OWN success page + * rather than ours. The row holds the app and the action this consent was for, and it cannot + * be missing here — this handler refused long before it needed an address — so there is no + * fallback to specify. + * + * AND BOTH HOLDERS LAND ON THE SAME SCREEN, which is the only one that reads `?action=`. An + * account the deployment holds used to be sent to `/admin/plugins/:key`, but that route has no + * `validateSearch` for `action`, no landing notice and no grant list, so the whole Shared-app + * flow completed a consent at the vendor and landed somewhere that said nothing about it. The + * settings page draws the Shared app's grant list and the landing notice for either holder, + * so this is where the action somebody just authorized is actually named and marked. + * + * `connectedAccountsUrlFor` with `{ serverId }` answers a query-less path, so appending + * `?action=` here cannot produce a second `?`. + * + * BUILT BEFORE THE WRITE RATHER THAN AFTER IT, so that the only thing standing between a + * committed grant and the person's browser is the redirect itself. Both reads are off the + * attempt row and neither calls anybody. + */ + const landing = `${connectedAccountsUrlFor(connect?.appUrl, { + serverId: attempt.serverId, + })}?action=${encodeURIComponent(attempt.action)}`; + + /* + * THE COMPLETION HAS ITS OWN `catch`, AND IT IS THE ONLY PLACE ON THIS LEG THAT MAY ANSWER + * `not-recorded`. + * + * THE DEFECT IT CLOSES, FIRST HALF: the blanket `catch` below used to answer `not-recorded` + * for every throw in this handler INCLUDING ONE RAISED AFTER THIS TRANSACTION HAD COMMITTED. + * The grant is live at the vendor and the row is written here, and the person was told it was + * not recorded — so they start over, mint a second consent against a connection they already + * have, and the one sentence this leg exists to get right is the one it got backwards. + * + * THE TWO ARE NOT TOLD APART BY WHETHER THE CALL THREW. The transaction is all-or-nothing + * ({@link PluginStore.completeAuthorizationAttempt}), but "the call rejected" and "the work + * rolled back" are different facts: a connection lost on the way back from a COMMIT the + * server already applied rejects here over a grant that IS written down. So the row the + * transaction serializes on is ASKED + * ({@link PluginStore.authorizationAttemptCompleted}) rather than inferred from the throw, + * and that answer — not the throw — decides which sentence the person gets. A probe that + * cannot be reached either reads as not recorded, which is the reading that sends somebody + * back to try again rather than away believing in a grant that is not there; the completion + * is idempotent on `completed_at IS NULL`, so a retry over a grant that did land writes + * nothing twice. + * + * SECOND HALF: IT IS LOGGED, which the blanket `catch` did not do at all. This is the one leg + * where a vendor grant can be LIVE with nothing here recording it, and it was also the one + * leg that left an operator no trace of that happening — every sibling on this route logs, + * and this is the same line `arcade-mint-not-recorded` writes at the other end of the same + * lifecycle, for the same reason: the `ar_…` is what lets an operator find that flow at the + * vendor and end it. + * + * WHAT IS IN THE LINE IS EXACTLY WHAT IS SAFE TO PUT THERE. The authorization id, the app and + * the action: the first is the handle the mint log already carries, and the other two are + * this deployment's own catalogue names. NOT the `authorization.url`, which is the bearer + * capability whoever opens it attaches the account with and which this handler never holds a + * copy of anyway; NOT the status reply, which carries a LIVE PROVIDER ACCESS TOKEN and the + * person's profile behind D31's narrowing; NOT the granted scopes and NOT the vendor user id, + * which name the person and buy an operator nothing the id does not. The error goes through + * {@link reasonWithoutStatement}, because a `DrizzleQueryError`'s own message is the failed + * statement and every value bound to it — which here includes the scope list and the account. + */ + try { + await store.completeAuthorizationAttempt({ + actorId, + authId, + toolkit: fresh.toolkit, + account: fresh.account, + action: attempt.action, + scopes: settled.scopes, + authProvider: authProvider ?? "", + }); + recorded = true; + } catch (error) { + recorded = await store + .authorizationAttemptCompleted({ actorId, authId }) + .catch(() => false); + console.error( + JSON.stringify({ + type: recorded + ? "arcade-completion-unacknowledged" + : "arcade-completion-not-recorded", + server: attempt.serverId, + action: attempt.action, + authorizationId: authId, + note: recorded + ? "A per-action consent was written down and the write then failed to report itself. The attempt row says it committed, so the person was sent on to the page that says so and nothing needs redoing." + : "A person completed a per-action consent at the vendor and this deployment could not write it down. The authorization named here is LIVE at the vendor with nothing here recording it; the person was told so, and the attempt row stands until its TTL.", + error: reasonWithoutStatement(error), + }), + ); + if (!recorded) return refusedWith("not-recorded"); + } + + return context.redirect(landing, 303); + } catch (error) { + /* + * AND THE BLANKET `catch` IS NO LONGER WHERE A LOST COMPLETION GOES QUIET. It stands over the + * reads that happen BEFORE the completion — the guard's session and role reads, the attempt + * read, and the refusals' own string building — so `not-recorded` is true of everything that + * reaches it: the completion above answers for itself, under its own `catch`, and never falls + * through to here still thinking it might have landed. + * + * `recorded` IS LOGGED RATHER THAN ASSUMED FALSE. The only step left after the completion is + * the redirect, and the sole way it can throw is a `Location` this deployment's own `appUrl` + * made unencodable — which would equally defeat the refusal built below, so there is no + * honest second answer to give here and the flag is what makes the claim checkable in a + * deployment rather than only in this comment. + * + * LOGGED AT ALL, which is the second half of what this leg was missing: every sibling on this + * route reports a failure it swallowed, and this one reported nothing. With the same + * restraint as the completion's own line above and nothing of the vendor's in it, through + * {@link reasonWithoutStatement} for the reason given there. + */ + console.error( + JSON.stringify({ + type: "arcade-verify-failed", + recorded, + note: "A check on the verifier raised instead of answering. The person was sent back to the list with a failure.", + error: reasonWithoutStatement(error), + }), + ); + return refusedWith("not-recorded"); + } }); } diff --git a/server/src/plugins/route-groups/context.ts b/server/src/plugins/route-groups/context.ts index 89939e659..c061485ce 100644 --- a/server/src/plugins/route-groups/context.ts +++ b/server/src/plugins/route-groups/context.ts @@ -1,10 +1,16 @@ import type { Hono, MiddlewareHandler } from "hono"; import type { BotAccessCheck } from "../../agents/profile-policy"; import type { AppVariables } from "../../auth/guards"; -import type { BrokerProvider } from "../access"; +import { BROKERED_PROFILE, type BrokerProvider } from "../access"; import type { AccountModeSwitch } from "../account-mode"; -import { brokerSentence, type ComposioBroker } from "../broker"; +import { + type AuthorizationFlow, + BrokerReturnUrlError, + brokerSentence, + type ComposioBroker, +} from "../broker"; import { vendorSentence } from "../composio"; +import type { AccountRef } from "../shared-accounts"; import type { SharedUseStore } from "../shared-use-store"; import type { PluginStore } from "../store"; @@ -71,15 +77,261 @@ export function brokerRefusal( } /** - * What a reader is told when Composio would not answer with its directory. + * A return address this deployment could not use, said once where an operator will read it. * - * One constant because two routes make that call — browsing the catalogue and enabling an app out of + * THE REMEDY AND THE READER HAD COME APART, WHICH IS WHAT THIS CLOSES. Both routes that check a + * return address are `requireUser`, so the sentence they answer with reaches somebody who pressed + * Connect — and `OPENBOT_APP_URL` is a variable they cannot set, named beside a vendor they may not + * even be on. {@link BrokerReturnUrlError} answers them with neither now, which would leave the + * operator's half nowhere at all if it were not written here: a misconfigured address would be + * invisible to the one person who can correct it. + * + * A LOG AND NOT A RESPONSE, because that is the difference between the two readers. The log is the + * operator's screen, and the sentence it carries is the error's own — written in `./broker`, naming + * the ACTIVE provider, and quoting no address, which is the promise that makes it safe to write + * down at all. Anything else thrown past this is somebody else's to explain, so it is ignored here + * rather than logged generically. + */ +export function logReturnUrlRefusal(error: unknown, serverId: string): void { + if (!(error instanceof BrokerReturnUrlError)) return; + console.error( + JSON.stringify({ + type: "broker-return-url-unusable", + serverId, + note: error.operatorMessage, + }), + ); +} + +/** + * What a reader is told when the active broker would not answer with its directory. + * + * One function because two routes make that call — browsing the catalogue and enabling an app out of * it — and a reader meeting the same failure through two doors should not meet two sentences. It * names the setting rather than quoting it: a key that was pasted with a space in it, or one for - * another project, is the likeliest reason Composio will not talk to this deployment at all. + * another project, is the likeliest reason the vendor will not talk to this deployment at all. + * + * THE VENDOR AND ITS SETTING COME OFF THE ACTIVE PROVIDER'S PROFILE, because the client shows the + * server's sentence in preference to its own fallback: a sentence that spelled one broker was read + * by an operator of the other. + */ +export function directoryUnavailable(provider: BrokerProvider): string { + const { vendor, envVar } = BROKERED_PROFILE[provider]; + return `${vendor} would not answer with its app directory, and said nothing about why. Check that ${envVar} is this project's key, and check ${vendor}'s status if it persists.`; +} + +/** + * Where a browser with no session is sent when it lands on the verifier, carrying what it arrived + * with. + * + * ABSOLUTE ON THE APP'S ORIGIN, for the reason {@link connectedAccountsUrlFor} is: the verifier + * lands on the API and the sign-in screen is a page, and locally those are two different ports — a + * relative `Location` would resolve against this server, which serves no pages at all. A deployment + * that serves both from one origin has no `appUrl`, and relative is correct there. + * + * THE HANDLE TRAVELS AS ITSELF AND NOT AS A RETURN ADDRESS. A `next=` parameter holding a URL is a + * redirect target somebody else gets a say in; this carries the one opaque value the return leg + * arrived with, so signing back in as the same person has everything it needs to resume and nothing + * it could be pointed elsewhere with. Encoded, because it is somebody else's id and this deployment + * does not know what is in it. + */ +export function signInUrlFor( + appUrl: string | undefined, + flowId: string, +): string { + const base = `${appUrl?.replace(/\/+$/, "") ?? ""}/sign`; + return flowId ? `${base}?flow_id=${encodeURIComponent(flowId)}` : base; +} + +/** + * WHY THE VERIFIER REFUSED, AS AN OPAQUE CODE — and the CLOSED SET of them. + * + * THE CODE TRAVELS AND THE SENTENCE DOES NOT. Each of these is rendered by the connected-accounts + * list, which holds the words; a message rendered from a query string is a message anybody can + * author on this deployment's own origin, and the code is also what keeps the copy in ONE place + * rather than in two that drift. The parameter is `?authorized=`, a channel of its own: `?connected=` + * belongs to the Composio OAuth callback and is neither reused nor widened here. + * + * ASSERTED AS A SET so an eleventh cannot be added without a test going red, and so the app's own map + * can be held to exactly the same list. + */ +export const AUTHORIZATION_REFUSALS = [ + // This deployment reaches no broker at all, so there is no return leg to verify. + "no-broker", + // The broker it reaches declares no correlated authorization flow. + "not-supported", + // The link arrived without the part that says which authorization it finishes. + "missing-handle", + // Nothing USABLE of this person's is waiting: never started, expired, another session's, a mint + // that never answered with an id, or a row that cannot say whose account it is for. + "no-attempt", + // The vendor was asked and did not answer. + "vendor-unavailable", + // The link, or the authorization behind it, is not the one this person is holding. + "not-your-authorization", + // The consent has not finished at the vendor yet. The attempt is LEFT STANDING for a retry. + "not-finished", + // The vendor says the authorization did not complete. + "authorization-failed", + // The app's account context moved on while the consent was open (D28). + "context-changed", + // This deployment could not write what it had, so it wrote none of it. + "not-recorded", +] as const; + +export type AuthorizationRefusal = (typeof AUTHORIZATION_REFUSALS)[number]; + +/** Whether two account references name the same account, holder included. */ +export function sameAccount(left: AccountRef, right: AccountRef): boolean { + if (left.vendorUserId !== right.vendorUserId) return false; + if (left.holder === "person") { + return right.holder === "person" && left.userId === right.userId; + } + return right.holder === "deployment"; +} + +/** + * A BROKER CALL THAT DID NOT ANSWER INSIDE ITS BOUND, AND CARRYING NOTHING OF THE VENDOR'S (D31). + * + * The message names this deployment's own bound and the thrown object has no `cause`, deliberately: + * a call that times out on the return leg is a call whose answer would have carried a LIVE provider + * access token and a full profile, and the one thing certain about a half-read answer is that + * nobody has narrowed it yet. So there is nothing here to attach, log or quote — which is the same + * promise {@link brokerRefusal} makes about the vendor's own errors, kept by having no vendor + * material to begin with. + * + * It is an ordinary `Error` to every reader of it: `brokerSentence` does not author it and + * `vendorSentence` finds no nested body, so every call site below answers it with its OWN generic + * sentence and a 502 — "we asked and got nothing back", which is exactly what happened. + * + * NOT EXPORTED, because nothing outside this file should be branching on it: every exit it can + * reach is already one of this file's own refusals, and a caller matching on the class would be + * re-deciding what the catch blocks here have decided. */ -export const DIRECTORY_UNAVAILABLE = - "Composio would not answer with its app directory, and said nothing about why. Check that COMPOSIO_API_KEY is this project's key, and check Composio's status if it persists."; +class VendorTimeoutError extends Error { + constructor(afterMs: number) { + super(`The broker did not answer within ${afterMs}ms.`); + this.name = "VendorTimeoutError"; + } +} + +/** + * How long ONE outbound call to the broker may take before this deployment stops waiting on it. + * + * EVERY CALL OUT OF THIS FILE GOES THROUGH {@link withVendorDeadline}, and that is the invariant + * rather than the number. A bound on how MANY times something is attempted is not a bound at all + * while each attempt is unbounded: a vendor that accepts the connection and then never writes a + * byte holds the socket, the handler and the browser open for as long as it cares to, and no retry + * limit, poll count or loop condition is ever reached because the first wait never ends. + */ +const VENDOR_CALL_TIMEOUT_MS = 10_000; + +/** + * The same bound on ONE return-leg status poll, and tighter because a browser is held open on it. + * + * Five polls at two seconds with four one-second pauses is a worst case of FOURTEEN seconds for the + * whole wait, and that arithmetic is the point of stating both numbers here. + */ +export const STATUS_CALL_TIMEOUT_MS = 2_000; + +/** + * At most five polls, pausing BETWEEN them — so four pauses, not five. NEVER the SDK's unbounded + * `waitForCompletion`. + */ +const STATUS_POLLS = 5; +const STATUS_PAUSE_MS = 1000; + +/** + * One outbound vendor call under a deadline, with an `AbortSignal` for the seams that take one. + * + * THE SIGNAL IS THE HALF THAT MATTERS UPSTREAM AND THE RACE IS THE HALF THAT MATTERS HERE. The race + * is what lets the handler answer and the browser go; aborting is what stops this deployment from + * holding a socket open against a vendor it has already given up on, and the seams that can carry + * a signal take it (see {@link AuthorizationFlow}). A seam that cannot is still bounded by the + * race — a timeout is a timeout to the person waiting either way — and its call is simply + * abandoned rather than cancelled. + * + * THE TIMER IS CLEARED ON BOTH OUTCOMES. A pending timer on a call that answered in time would keep + * an event loop alive for the rest of the bound, which in a test run is a suite that will not exit + * and in a deployment is a handler that has returned still holding a reason not to. + * + * NOTHING IS RETRIED HERE. A deadline that retried would be the very shape this fix exists to + * remove, one layer up. + */ +export function withVendorDeadline( + call: (signal: AbortSignal) => Promise, + afterMs: number = VENDOR_CALL_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new VendorTimeoutError(afterMs)); + }, afterMs); + }); + /* + * A CALL THAT THROWS BEFORE IT RETURNS A PROMISE IS A REJECTION, NOT AN ESCAPE. The deadline is + * already armed by this line, so invoking `call` bare inside the race would let a synchronous + * throw — `brokerReturnUrl` refusing a scheme-less `OPENBOT_APP_URL` while the callback builds its + * argument is the one that happens — leave this function before the race and its `.finally` + * exist: the timer stays armed, aborts nothing anybody is waiting on and rejects `deadline` with + * no one subscribed, and a caller's `.catch(...)` on the returned promise is skipped entirely. + * Calling it inside an executor turns that throw into this promise's rejection, still + * synchronously and still exactly once. + */ + const answer = new Promise((resolve) => resolve(call(controller.signal))); + /* + * `Promise.race` subscribes to both, so a call that answers AFTER the deadline has its rejection + * observed here and never surfaces as an unhandled one. Its answer is dropped unread, which is + * also the D31-safe thing to do with it. + */ + return Promise.race([answer, deadline]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +/** + * Where one authorization has got to, waited for with a BOUND ON THE WHOLE WAIT AND ON EACH CALL. + * + * `pending` is the only state worth asking again about, and five polls at two seconds each with a + * one-second pause between them — at most fourteen seconds, and the pauses are BETWEEN the polls, + * so four of them and not five — is the whole of the patience this has. A timeout is neither a + * success nor a failure, so what comes back is the last answer read and the caller decides; nothing + * here writes, retries forever or blocks a browser on a vendor that has stopped answering. + * + * THE POLL COUNT ALONE NEVER BOUNDED ANYTHING. It bounded how many times `flow.status` was CALLED, + * while each call waited as long as the vendor liked — so a vendor that accepted the connection and + * never answered held this request open forever, which is the one case the paragraph above claims + * to prevent. {@link withVendorDeadline} is what makes the claim true, and a poll that spends its + * own bound THROWS rather than counting: a vendor that will not answer in two seconds is a vendor + * that is not answering, and the caller's `vendor-unavailable` refusal leaves the attempt row + * standing exactly as the exhausted-polls refusal does. + * + * WHAT COMES BACK IS THE SEAM'S THREE FIELDS AND NOTHING MORE (D31). The vendor's own status reply + * carries a live provider access token and the person's full profile; the adapter drops them, and + * this function must never widen what it hands on, log it, or attach it as a `cause`. + */ +export async function settledStatus( + flow: AuthorizationFlow, + id: string, +): Promise>> { + const poll = () => + withVendorDeadline( + (signal) => flow.status(id, { signal }), + STATUS_CALL_TIMEOUT_MS, + ); + let answer = await poll(); + for ( + let polls = 1; + polls < STATUS_POLLS && answer.status === "pending"; + polls += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, STATUS_PAUSE_MS)); + answer = await poll(); + } + return answer; +} /** What the connect flow needs that the store does not hold. See `createPluginRoutes`. */ export type PluginConnectOptions = { @@ -126,6 +378,12 @@ export type PluginRouteContext = { connect: PluginConnectOptions | undefined; composio: { broker: ComposioBroker; provider: BrokerProvider } | undefined; shared: { modes: AccountModeSwitch; use: SharedUseStore } | undefined; + /** The provider this deployment's brokered rows belong to. See `createPluginRoutes`. */ + activeProvider: BrokerProvider; + /** {@link activeProvider}'s name as the sentences on this layer spell it. */ + activeVendor: string; + /** Which app a brokered row is, read by {@link activeProvider}'s own parser. */ + appOfUrl: (url: string) => string | null; actorEmail: (context: { var: AppVariables }) => string; skillActor: (context: { var: AppVariables }) => { id: string; diff --git a/server/src/plugins/route-groups/servers.ts b/server/src/plugins/route-groups/servers.ts index 9c51fdab2..0eb831c6d 100644 --- a/server/src/plugins/route-groups/servers.ts +++ b/server/src/plugins/route-groups/servers.ts @@ -2,7 +2,7 @@ import { requireAdmin } from "../../auth/guards"; import { reasonWithoutStatement } from "../../db/query-failure"; import { brokerSentence, schemeKind } from "../broker"; import { CATALOGUE } from "../catalogue"; -import { toolkitOf, vendorSentence } from "../composio"; +import { vendorSentence } from "../composio"; import { redirectUriFor } from "../oauth"; import { asAccountMode } from "../shared-accounts"; import { asSharedUseApproval, type SharedUseApproval } from "../shared-use"; @@ -22,6 +22,9 @@ export function mountServerRoutes({ connect, composio, shared, + activeProvider, + activeVendor, + appOfUrl, actorEmail, skillActor, }: PluginRouteContext) { @@ -61,6 +64,46 @@ export function mountServerRoutes({ * screen needs is whether to draw the app directory, which is a yes or a no. */ composioConfigured: Boolean(composio), + /* + * Whether this deployment's broker authorizes ONE ACTION AT A TIME, as a declared capability. + * + * A PRESENCE TEST ON A VALUE THE BROKER DECLARED, computed from the same expression the two + * routes that run that flow gate on — `/servers/:id/authorize` and the `/arcade/verify` + * return leg both read `composio.broker.authorizations`. The screen that draws the per-action + * grant list used to gate on `provider === "arcade"` instead, so the screen and the routes + * keyed on different facts: a broker named arcade reached with `authorizations` null would + * have drawn the list and 503'd every Authorize press. See {@link + * ConnectedAppBroker.authorizations} and `store.ts`'s gate comment, which records a defect of + * exactly that shape. + * + * A capability, never a name: what the screen asks is what the broker can DO, and two + * providers are free to share an answer. `provider` beside it stays a NAME and answers a + * different question — which vendor to put in copy. + * + * False on Composio, whose auth config fixes one state for a whole toolkit, so every Composio + * render is unchanged. + */ + authorizesPerAction: Boolean(composio?.broker.authorizations), + /* + * WHICH vendor brokers this deployment's apps, as a name. + * + * Handed in with the broker, never read off it: the broker declares what it can do, not who + * it is, and a name sniffed from it would be the comparison the store argues against. + * + * A screen that FILTERS by brokered-ness needs no provider name. One provider holds rows at a + * time, so a predicate over a row's provenance is enough. A screen that NAMES a vendor + * ("Browse Composio", the `/admin/plugins/composio` route, the key's setting, "Reached + * through Composio") cannot derive that name from a row, and this is the only thing that + * answers it. It is separate from `composioConfigured`, which asks whether the directory can + * be browsed at all. + * + * A NAME, never a key and never a capability. The Plugins page is reachable by any signed-in + * person, so nothing about the vendor key (its presence, shape or anything derived from it) + * travels with this. Composio when no broker is configured, so that deployment is unchanged — + * read from {@link activeProvider}, which is also what the brokered reads below parse with, so + * the name this page shows and the parser those routes use cannot come apart. + */ + provider: activeProvider, servers: await store.listServers(), // Scoped: the deployment's skills plus this person's own. An administrator sees them all. skills: await store.listSkills(skillActor(context)), @@ -358,7 +401,7 @@ export function mountServerRoutes({ JSON.stringify({ type: "mcp-server-not-removed", server: serverId, - note: "Removing a server failed for a reason neither this deployment nor Composio put a sentence to. The administrator was answered 502 with the generic sentence, and the server row is still there.", + note: `Removing a server failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence, and the server row is still there.`, error: reasonWithoutStatement(error), }), ); @@ -366,12 +409,36 @@ export function mountServerRoutes({ const refusal = brokerRefusal( error, - "Removing this app did not finish, and neither this deployment nor Composio said why, so the app is still here rather than gone with grants of its own left live at the vendor. Press Remove again: every account is withdrawn at Composio before anything here is deleted, so repeating it is safe and asks only for what is left. Check this deployment's Composio key if it persists.", + `Removing this app did not finish, and neither this deployment nor ${activeVendor} said why, so the app is still here rather than gone with grants of its own left live at the vendor. Press Remove again: every account is withdrawn at ${activeVendor} before anything here is deleted, so repeating it is safe and asks only for what is left. Check this deployment's ${activeVendor} key if it persists.`, ); return context.json({ error: refusal.error }, refusal.status); } - if (wasShared) - await shared?.modes.forgetRule(serverId, context.var.actor.id); + /* + * THE SAME SHAPE AS THE MINT'S RECORD-AFTER-THE-FACT BELOW, AND GUARDED FOR THE SAME REASON. + * `removeServer` has already returned, which means every account was withdrawn AT THE VENDOR + * and every row here is gone — none of it undoable, and none of it repeatable either: a second + * Remove finds no app. Unhandled, a fault tidying up the one leftover ask-before-write rule + * answered that completed removal with a bodyless 500, so the administrator was told the + * removal failed and had no press left to make. + * + * SO THE ANSWER STAYS `ok`, BECAUSE THE REMOVAL REALLY DID HAPPEN, and the leftover is named on + * the console instead: a revoked-rule row that outlives the app it guarded, which an + * administrator can retire from the rules screen and which grants nobody anything meanwhile. + */ + if (wasShared) { + try { + await shared?.modes.forgetRule(serverId, context.var.actor.id); + } catch (error) { + console.error( + JSON.stringify({ + type: "shared-rule-not-forgotten", + server: serverId, + note: "An app was removed, at the vendor and here, and its ask-before-write rule could not be retired afterwards. The administrator was answered ok, because the removal is done and cannot be repeated; the rule is still standing.", + error: reasonWithoutStatement(error), + }), + ); + } + } return context.json({ ok: true }); }); @@ -398,7 +465,7 @@ export function mountServerRoutes({ return context.json({ error: "Choose personal or shared." }, 400); const serverId = context.req.param("id"); const row = await store.serverAddress(serverId); - if (!row || !toolkitOf(row.url)) { + if (!row || !appOfUrl(row.url)) { return context.json( { error: "That app is not reached through a broker." }, 400, diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index a5aa59440..e78ce8b17 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -1,8 +1,8 @@ import { Hono, type MiddlewareHandler } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; +import { BROKERED_PROFILE, type BrokerProvider, toolkitOfFor } from "./access"; import type { AccountModeSwitch } from "./account-mode"; -import type { BrokerProvider } from "./access"; import type { ComposioBroker } from "./broker"; import { mountComposioAppRoutes } from "./route-groups/composio-apps"; import { mountConnectionRoutes } from "./route-groups/connections"; @@ -17,7 +17,11 @@ import { mountSkillRoutes } from "./route-groups/skills"; import type { SharedUseStore } from "./shared-use-store"; import type { PluginStore } from "./store"; -export type { ConnectingPersonCheck } from "./route-groups/context"; +export type { + AuthorizationRefusal, + ConnectingPersonCheck, +} from "./route-groups/context"; +export { AUTHORIZATION_REFUSALS } from "./route-groups/context"; /** * The Plugins surface: what this deployment has added, and which Bots may use it. @@ -85,6 +89,80 @@ export function createPluginRoutes( ) { const routes = new Hono<{ Variables: AppVariables }>(); + /** + * WHICH broker this deployment's rows are reached through, as a name, read once for the whole + * layer. + * + * HANDED IN WITH THE BROKER AND NEVER SNIFFED OFF IT. The broker declares what it can DO — + * `grantIsLocal`, `authorizations` — deliberately not who it is, so a vendor name derived from it + * would be the comparison `store.ts` argues against, made against a capability that two providers + * are free to share. + * + * TAKEN FROM THE STORE, BECAUSE THE STORE'S ROWS ARE WHAT IS BEING NAMED. It used to be read off + * the broker bag with `?? "composio"` behind it, and that `??` was a second answer to a question + * `server/src/index.ts` had already answered. The two came apart exactly where it hurt: a + * deployment with both API keys set and `OPENBOT_BROKER_PROVIDER` unset — legal, and what a + * migration sits in — is wired with NO broker, so the bag is absent, so every `arcade://` row of + * that deployment was parsed with Composio's parser. The account-mode switch refused those rows + * as "not reached through a broker", the directory called enabled apps not enabled, connect fell + * past its brokered branch in silence, and the operator was told to set COMPOSIO_API_KEY. The + * store was built with the one name the deployment decided; reading it here means the parser + * these routes use, the filter the store queries with and the name the page shows are one value. + * + * NO BROKER IS STILL NOT THE CASE THIS DECIDES. Rows outlive a key being unset, and the + * Personal ⇄ Shared switch is wired from the shared bag rather than from the broker, so it + * answers for such a row today — now with that row's own parser rather than Composio's. + */ + const activeProvider: BrokerProvider = store.brokerProvider; + /* + * Said at construction, not at the first request. A store without this field is a stand-in rather + * than a store, and one that got this far would have every brokered read dispatch on `undefined` + * — four routes answering wrongly, none of them raising. Typed as present, so this catches only a + * cast; it refuses rather than filling in a vendor, which is the whole of this file's bug. + */ + if (!activeProvider) { + throw new Error( + "createPluginRoutes was given a plugin store with no brokerProvider. The store names the provider its rows belong to; the routes never guess one.", + ); + } + + /** + * The ACTIVE broker's name, as the sentences on this layer spell it. + * + * ONE READING FOR EVERY SENTENCE, AND NEVER A LITERAL. Every refusal below used to spell + * "Composio" into the prose — the app that would not be removed, the app that would not be + * enabled, the six ways a key app's connect fails, the consent with nowhere to send anybody, and + * the confirm, re-check and disconnect tails. On an Arcade deployment each of them named a vendor + * this deployment holds no key for and never dials, and sent the reader to check a key that does + * not exist. The name comes off {@link activeProvider}'s profile, which is also what the brokered + * reads below parse with, so the vendor a sentence names and the vendor a route actually reached + * cannot come apart. + * + * THE PROFILE RATHER THAN A TERNARY, because {@link BROKERED_PROFILE} is where a provider's + * vocabulary is written down once — a second spelling here is the second home the record exists + * to prevent, and a provider added to it without being added here would typecheck. + */ + const activeVendor = BROKERED_PROFILE[activeProvider].vendor; + + /** + * Which app a brokered row is, read by the parser belonging to the broker this deployment runs. + * + * ONE PARSER PER PROVIDER, AND THEY ARE NOT INTERCHANGEABLE — the argument is {@link toolkitOfFor}'s + * and is not repeated here. What this layer adds is that all four reads below used to call + * Composio's parser by name, and that parser answers null for `arcade://Gmail` by design. Four + * reads, four different wrong answers on an Arcade deployment, and not one of them a failure or a + * log line: the account-mode switch refused a brokered app as "not reached through a broker", the + * directory reported every enabled app as not enabled, the connect route fell past its brokered + * branch in silence, and `brokeredAccountFor` — step one of confirm, re-check and disconnect — + * refused a connected app before any account was resolved. + * + * A CLOSURE RATHER THAN THE DISPATCH SPELLED FOUR TIMES, which is how `shared-use-store.ts` holds + * the same thing: the provider is read in one place, so a fifth read added below cannot be the one + * that forgets to pass it. + */ + const appOfUrl = (url: string): string | null => + toolkitOfFor(activeProvider, url); + const actorEmail = (context: { var: AppVariables }) => context.var.actor?.email ?? "unknown"; @@ -121,6 +199,9 @@ export function createPluginRoutes( connect, composio, shared, + activeProvider, + activeVendor, + appOfUrl, actorEmail, skillActor, skillRefusal, diff --git a/server/tests/pending-authorization-audit-ledger.test.ts b/server/tests/pending-authorization-audit-ledger.test.ts index 2b77e47c3..b38851394 100644 --- a/server/tests/pending-authorization-audit-ledger.test.ts +++ b/server/tests/pending-authorization-audit-ledger.test.ts @@ -150,56 +150,58 @@ const CALL_SITES: Record< string, { anchor: string; transitions: Transition[] }[] > = { - "server/src/plugins/routes.ts :: claimAuthorizationAttempt": [ - { - anchor: "if (!claim.claimed)", - transitions: [ - { - name: "claim (a person starts an authorization)", - debt: "B5e", - note: "The moment a signed-in person starts a consent leaves no row on the trail, so nothing records who started which attempt.", - }, - { - name: "expiry overwrite (re-claim silently replaces an expired or settled attempt)", - debt: "B5e", - note: "An attempt that expired while still live is never recorded as having ended. The next claim's ON CONFLICT DO UPDATE erases it.", - }, - ], - }, - ], - "server/src/plugins/routes.ts :: discardAuthorizationAttempt": [ - { - anchor: "arcade-refused-attempt-not-cleared", - transitions: [ - { - name: "discard after the vendor refused the mint", - debt: "B5e", - note: "A started attempt ends with only a console line on cleanup failure. A refusal on a consent lifecycle should be on the trail, as `mcp.callback_refused` and `session.refused` are elsewhere.", - }, - ], - }, - { - anchor: "arcade-mint-not-recorded", - transitions: [ - { - name: "discard after the mint succeeded but could not be recorded", - debt: "B5e", - note: "A flow is LIVE at the vendor and the only record is the `arcade-mint-not-recorded` console line. This is the strongest case for an event in the ledger.", - }, - ], - }, - { - anchor: "const cancelled = await store.discardAuthorizationAttempt", - transitions: [ - { - name: "withdrawn by the person (cancel)", - audited: "mcp.authorization_cancelled", - filedBy: "recordAuthorizationCancelled", - }, - ], - }, - ], - "server/src/plugins/routes.ts :: recordAuthorizationMint": [ + "server/src/plugins/route-groups/connections.ts :: claimAuthorizationAttempt": + [ + { + anchor: "if (!claim.claimed)", + transitions: [ + { + name: "claim (a person starts an authorization)", + debt: "B5e", + note: "The moment a signed-in person starts a consent leaves no row on the trail, so nothing records who started which attempt.", + }, + { + name: "expiry overwrite (re-claim silently replaces an expired or settled attempt)", + debt: "B5e", + note: "An attempt that expired while still live is never recorded as having ended. The next claim's ON CONFLICT DO UPDATE erases it.", + }, + ], + }, + ], + "server/src/plugins/route-groups/connections.ts :: discardAuthorizationAttempt": + [ + { + anchor: "arcade-refused-attempt-not-cleared", + transitions: [ + { + name: "discard after the vendor refused the mint", + debt: "B5e", + note: "A started attempt ends with only a console line on cleanup failure. A refusal on a consent lifecycle should be on the trail, as `mcp.callback_refused` and `session.refused` are elsewhere.", + }, + ], + }, + { + anchor: "arcade-mint-not-recorded", + transitions: [ + { + name: "discard after the mint succeeded but could not be recorded", + debt: "B5e", + note: "A flow is LIVE at the vendor and the only record is the `arcade-mint-not-recorded` console line. This is the strongest case for an event in the ledger.", + }, + ], + }, + { + anchor: "const cancelled = await store.discardAuthorizationAttempt", + transitions: [ + { + name: "withdrawn by the person (cancel)", + audited: "mcp.authorization_cancelled", + filedBy: "recordAuthorizationCancelled", + }, + ], + }, + ], + "server/src/plugins/route-groups/connections.ts :: recordAuthorizationMint": [ { anchor: "authId: minted.authorizationId", transitions: [ @@ -211,64 +213,68 @@ const CALL_SITES: Record< ], }, ], - "server/src/plugins/routes.ts :: failAuthorizationAttempt": [ - { - anchor: 'settled.status === "failed"', - transitions: [ - { - name: "failed (the vendor reported the consent failed)", - debt: "B5e", - note: "A terminal verdict on the attempt with no trail row.", - }, - ], - }, - { - anchor: "settled.userId !== attempt.vendorUserId", - transitions: [ - { - name: "failed (the consent completed for an identity that is not the attempt's)", - debt: "B5e", - note: "A return leg that came back bound to somebody else is the exact attribution failure this lifecycle exists to refuse, and the refusal leaves no row.", - }, - ], - }, - ], - "server/src/plugins/routes.ts :: markAuthorizationWithdrawn": [ - { - anchor: "if (withdrawn)", - transitions: [ - { - name: "withdrawn (context changed; the vendor withdrew the retired grant)", - debt: "B5e", - note: "Only indirectly audited. `clearWithdrawnAccount` files `mcp.account_disconnected` rows for what it cleared, but the verdict on the attempt itself is not filed.", - }, - ], - }, - ], - "server/src/plugins/routes.ts :: markAuthorizationUnreconciled": [ - { - anchor: "else await store.markAuthorizationUnreconciled", - transitions: [ - { - name: "unreconciled (context changed; withdrawing the retired grant was not confirmed)", - debt: "B5e", - note: "The case where a grant may still be live at the vendor, and the only record is a console line.", - }, - ], - }, - ], - "server/src/plugins/routes.ts :: completeAuthorizationAttempt": [ - { - anchor: "recorded = true", - transitions: [ - { - name: "completed", - audited: "mcp.account_connected", - filedBy: "completeAuthorizationAttempt", - }, - ], - }, - ], + "server/src/plugins/route-groups/connections.ts :: failAuthorizationAttempt": + [ + { + anchor: 'settled.status === "failed"', + transitions: [ + { + name: "failed (the vendor reported the consent failed)", + debt: "B5e", + note: "A terminal verdict on the attempt with no trail row.", + }, + ], + }, + { + anchor: "settled.userId !== attempt.vendorUserId", + transitions: [ + { + name: "failed (the consent completed for an identity that is not the attempt's)", + debt: "B5e", + note: "A return leg that came back bound to somebody else is the exact attribution failure this lifecycle exists to refuse, and the refusal leaves no row.", + }, + ], + }, + ], + "server/src/plugins/route-groups/connections.ts :: markAuthorizationWithdrawn": + [ + { + anchor: "if (withdrawn)", + transitions: [ + { + name: "withdrawn (context changed; the vendor withdrew the retired grant)", + debt: "B5e", + note: "Only indirectly audited. `clearWithdrawnAccount` files `mcp.account_disconnected` rows for what it cleared, but the verdict on the attempt itself is not filed.", + }, + ], + }, + ], + "server/src/plugins/route-groups/connections.ts :: markAuthorizationUnreconciled": + [ + { + anchor: "else await store.markAuthorizationUnreconciled", + transitions: [ + { + name: "unreconciled (context changed; withdrawing the retired grant was not confirmed)", + debt: "B5e", + note: "The case where a grant may still be live at the vendor, and the only record is a console line.", + }, + ], + }, + ], + "server/src/plugins/route-groups/connections.ts :: completeAuthorizationAttempt": + [ + { + anchor: "recorded = true", + transitions: [ + { + name: "completed", + audited: "mcp.account_connected", + filedBy: "completeAuthorizationAttempt", + }, + ], + }, + ], }; /** Every foreign key that deletes rows of this table when its parent goes, keyed by constraint. */ @@ -299,7 +305,7 @@ const NON_WRITES: Record< { file: string; anchor: string } & Transition > = { "cancel that lost the race to a completion": { - file: "server/src/plugins/routes.ts", + file: "server/src/plugins/route-groups/connections.ts", anchor: "That authorization finished before it could be cancelled", name: "cancel refused because the attempt had already completed", debt: "B2c", From b4e48c708734bc892eafad4cb0bdbd2861699369 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 17:18:17 +0200 Subject: [PATCH 217/232] test(compose): assert the migrator compose actually runs compose.yml's migrate service runs ["bun", "scripts/migrate.ts"] and says at the service why: drizzle-kit will skip a migration stamped below what the database has already recorded and still report success, which is the outcome server/src/db/migration-reach.ts exists to refuse, and a developer's database is one of the places it happens. The test still asserted the drizzle-kit command, so it failed in CI the first time the whole suite ran against this branch. Wave 2's C4 reported this file as 'looks stale' and correctly did not fix it under the one-finding rule; nobody owned it afterwards. Updated to the command compose runs, with the reason recorded so the assertion reads as a constraint rather than a transcription. Intent is unchanged: the service exists, it waits for a healthy database, and it runs the migrator that refuses an out-of-reach migration. tests/compose.test.ts 17 pass / 0 fail. Co-Authored-By: Claude Opus 5 (1M context) --- tests/compose.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/compose.test.ts b/tests/compose.test.ts index 314034286..8871209c2 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -447,7 +447,16 @@ test("runs migrations after PostgreSQL becomes healthy", () => { expect(compose).toContain("migrate:"); expect(compose).toContain("condition: service_healthy"); - expect(compose).toContain('"drizzle-kit", "migrate"'); + /* + * THE SERVER'S OWN MIGRATOR, NOT `drizzle-kit migrate`, AND THE DIFFERENCE IS THE POINT. + * + * `drizzle-kit` will skip a migration stamped below what the database has already recorded and + * still report success — the exact outcome `server/src/db/migration-reach.ts` exists to refuse, + * and a developer's database is one of the places it happens. `compose.yml` says the same thing + * at the service itself. Asserting the command here is what stops the stack quietly going back + * to the migrator that cannot see that case. + */ + expect(compose).toContain('"bun", "scripts/migrate.ts"'); }); test("builds the deployment image with Playwright's Chromium payload only", () => { From 695fdc89c4e4b82e5a0489b9ab7694af11a61b6a Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 17:29:00 +0200 Subject: [PATCH 218/232] fix(test-guard): judge raw sockets by the address they reach; assert proxy refusals on the guard's record CI was red on two counts after C7 hardened the test network guard. 1. test-network-guard.test.ts pinned the runtime's wording of a proxy refusal ("response 403 refused"). On CI's Linux runner Bun.fetch and undici instead throw "Proxy refused to open a tunnel: 403 Forbidden". The proxied cases now accept only the guard's own 403 response (with its header) or a throw, and never a response the guard did not write; that the guard stopped the call is still asserted on openbotTestNetworkRefusals gaining exactly [target]. Planted: with the proxy env unset, all four go red on "response 404 unguarded". 2. agent-computer's egress suites open sockets at rebind.example.test, dual.example.test and 10.255.255.1 because the socket layer is their subject. guardSocketHost now also lets through literal addresses the public internet does not route (RFC 1918, RFC 5737/3849 documentation ranges, IPv6 ULA; link-local and so the metadata service stay refused). At net.Socket#connect only, an RFC 6761 special-use name (.test, .invalid, .example) is let through with its lookup wrapped, so it connects only if every address it resolves to passes the same rule: a lookup option or /etc/hosts cannot smuggle a vendor address past it. fetch, http(s).request/get, ClientRequest, http2 and WebSocket stay loopback-only. The database-host case moves from db.openbot.invalid to db.openbot.home.arpa, since .invalid is now admitted on its own terms and the case could no longer show the database allowance exists. guardSocketHost call sites (all now accept unroutable literals): net.Socket.prototype.connect, Bun.connect, dgram Socket send/connect, Bun.udpSocket connect/send/sendMany. Co-Authored-By: Claude Opus 5.5 --- server/scripts/test-network-guard.ts | 150 ++++++++++++++++++++- server/tests/test-network-guard.test.ts | 170 ++++++++++++++++++++++-- 2 files changed, 308 insertions(+), 12 deletions(-) diff --git a/server/scripts/test-network-guard.ts b/server/scripts/test-network-guard.ts index 17e70cea8..bf52b4516 100644 --- a/server/scripts/test-network-guard.ts +++ b/server/scripts/test-network-guard.ts @@ -41,6 +41,25 @@ * `DATABASE_URL`, because that is how the Postgres driver reaches the test database and that host is * deployment configuration, not necessarily loopback. * + * AND RAW SOCKETS ARE JUDGED BY THE ADDRESS THEY REACH, because some suites' SUBJECT is the socket + * layer. `agent-computer`'s egress filter has to prove it pins the address it checked, falls back + * to the next one, and gives up on one that never answers, and it can only prove that by opening + * sockets at `rebind.example.test`, `dual.example.test` and `10.255.255.1`. None of those can be a + * vendor, and the rule that admits them says why — not a list of the hosts a suite happens to use: + * - a LITERAL address the public internet does not route (RFC 1918 private, the RFC 5737/3849 + * documentation ranges, IPv6 unique-local). Link-local is excluded on purpose: 169.254.169.254 is + * the cloud metadata service, which hands out credentials. + * - a name under a top-level domain RFC 6761 reserves so public DNS never answers it (`.test`, + * `.invalid`, `.example`) — and ONLY with its lookup wrapped, because a `lookup` option or + * `/etc/hosts` can map a reserved name to any address. The wrapped lookup lets the connection + * proceed only if every address it answers is one a literal would be let through to, so a name + * is never the way round the address rule. + * This is at `net.Socket#connect` only, where a lookup can be checked. Every other entry point + * (`fetch`, `http(s).request`/`get`, `http2`, `WebSocket`) stays loopback-only, and + * `Bun.connect`/UDP take the same literal-address rule but no names. + * Why not a named opt-out like `OPENBOT_LIVE_*`: `bun run test:ci` runs every file in ONE process, + * so a flag one suite set would unguard every file after it. + * * WHAT IS NOT GUARDED, AND WHY — none of these is how any code in this repository talks to a vendor: * `Bun.SQL` (native; it IS the database path, and its connection never surfaces to JavaScript); * `Bun.S3Client`/`Bun.redis` (native clients this repository does not use); a `fetch` given an @@ -57,6 +76,7 @@ */ import dgram from "node:dgram"; +import dns from "node:dns"; import http from "node:http"; import http2 from "node:http2"; import https from "node:https"; @@ -157,11 +177,132 @@ function socketConnectHost(args: unknown[]): string | null { return typeof second === "string" ? second : "localhost"; } +/** + * Addresses no vendor can be behind, because the public internet does not route them: RFC 1918 + * private space, the RFC 5737 / RFC 3849 documentation ranges, and IPv6 unique-local space + * (RFC 4193). Loopback is judged by `isLoopback`. Link-local (169.254/16, fe80::/10) is + * deliberately NOT here: 169.254.169.254 is the cloud metadata service, which hands out credentials. + */ +const unroutable = new net.BlockList(); +unroutable.addSubnet("10.0.0.0", 8, "ipv4"); +unroutable.addSubnet("172.16.0.0", 12, "ipv4"); +unroutable.addSubnet("192.168.0.0", 16, "ipv4"); +unroutable.addSubnet("192.0.2.0", 24, "ipv4"); +unroutable.addSubnet("198.51.100.0", 24, "ipv4"); +unroutable.addSubnet("203.0.113.0", 24, "ipv4"); +unroutable.addSubnet("fc00::", 7, "ipv6"); +unroutable.addSubnet("2001:db8::", 32, "ipv6"); + +/** A LITERAL address that is loopback or that the public internet does not route. Never a name. */ +function isUnroutableAddress(host: string): boolean { + const bare = host.replace(/^\[|\]$/g, "").toLowerCase(); + const family = net.isIP(bare); + if (family === 0) return false; + return ( + isLoopback(bare) || unroutable.check(bare, family === 6 ? "ipv6" : "ipv4") + ); +} + +/** + * A name under a top-level domain RFC 6761 reserves so that public DNS never answers for it: + * `.test`, `.invalid`, `.example` (`.localhost` is loopback already). + * + * BEING RESERVED IS NOT ENOUGH ON ITS OWN, so no entry point lets such a name through bare: + * `/etc/hosts`, a local resolver, or a `lookup` option the caller hands in can map any name to any + * address. A special-use name is let through only at `net.Socket#connect`, where its lookup is + * replaced by `checkedLookup`, which judges the ADDRESS it resolves to by the same rule as a literal. + */ +function isSpecialUseName(host: string): boolean { + return net.isIP(host) === 0 && /\.(test|invalid|example)\.?$/i.test(host); +} + function guardSocketHost(host: string | null, label: string): void { - if (host === null || isLoopback(host) || isDatabaseHost(host)) return; + if ( + host === null || + isLoopback(host) || + isDatabaseHost(host) || + isUnroutableAddress(host) + ) + return; throw refuse(`${label} ${host}`); } +type LookupCallback = ( + error: Error | null, + address?: string | Array<{ address: string; family: number }>, + family?: number, +) => void; +type Lookup = ( + hostname: string, + options: object | LookupCallback, + callback?: LookupCallback, +) => void; + +/** + * The caller's `lookup` (or the system resolver), passed on only when every address it answers is + * one `guardSocketHost` would let a literal through to. Otherwise the socket gets the refusal as its + * connection error and never dials the address. + */ +function checkedLookup(lookup: Lookup | undefined): Lookup { + const resolve = lookup ?? (dns.lookup as unknown as Lookup); + return (hostname, options, callback) => { + const done = ( + typeof options === "function" ? options : callback + ) as LookupCallback; + resolve( + hostname, + typeof options === "function" ? {} : options, + (error, address, family) => { + if (error) return done(error); + const answered = Array.isArray(address) + ? address.map((entry) => entry.address) + : [String(address)]; + const far = answered.find( + (candidate) => !isUnroutableAddress(candidate), + ); + if (far !== undefined) + return done(refuse(`socket ${hostname} resolved to ${far}`)); + done(null, address, family); + }, + ); + }; +} + +/** + * `net.Socket#connect` arguments aimed at a special-use name, rewritten with the lookup checked. + * The overloads a name arrives in — `(options, cb)`, `(port, host, cb)`, and the pre-normalized + * `[options, cb]` array Node's own `net.connect` passes — are covered. `null` for any other shape, + * which `guardSocketHost` then refuses like any other name. + */ +function withCheckedLookup(args: unknown[]): unknown[] | null { + const [first, second] = args; + if (Array.isArray(first)) { + // The normalized array is tagged by the runtime (a copy is not recognised as one and is + // rejected as missing arguments), so it is rewritten in place rather than rebuilt. + const inner = withCheckedLookup(first); + if (!inner) return null; + first.splice(0, first.length, ...inner); + return args; + } + if (first && typeof first === "object") { + const options = first as { lookup?: Lookup }; + return [ + { ...options, lookup: checkedLookup(options.lookup) }, + ...args.slice(1), + ]; + } + if ( + (typeof first === "number" || /^\d+$/.test(String(first))) && + typeof second === "string" + ) { + return [ + { port: Number(first), host: second, lookup: checkedLookup(undefined) }, + ...args.slice(2), + ]; + } + return null; +} + type Callable = (...args: unknown[]) => unknown; type Constructable = new (...args: unknown[]) => object; @@ -261,7 +402,12 @@ if (!liveRun) { net.Socket.prototype, "connect", function (this: net.Socket, ...args: unknown[]) { - guardSocketHost(socketConnectHost(args), "socket"); + const host = socketConnectHost(args); + if (host !== null && isSpecialUseName(host) && !isDatabaseHost(host)) { + const checked = withCheckedLookup(args); + if (checked) return originalSocketConnect.apply(this, checked); + } + guardSocketHost(host, "socket"); return originalSocketConnect.apply(this, args); }, ); diff --git a/server/tests/test-network-guard.test.ts b/server/tests/test-network-guard.test.ts index 29a33ed37..c17e9640d 100644 --- a/server/tests/test-network-guard.test.ts +++ b/server/tests/test-network-guard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import dgram from "node:dgram"; -import http from "node:http"; +import http, { request as namedHttpRequest } from "node:http"; import http2 from "node:http2"; import https, { get as namedHttpsGet } from "node:https"; import net from "node:net"; @@ -45,16 +45,27 @@ async function plant( ? `response ${value.status} ${value.headers.get("x-openbot-test-network-guard") ?? "unguarded"}` : "returned"; } catch (error) { - outcome = error instanceof Error ? error.message : String(error); + outcome = `threw: ${error instanceof Error ? error.message : String(error)}`; } return { outcome, refused: record.slice(before) }; } const refusedCall = "Tests do not use the network"; -const refusedByProxy = "response 403 refused"; + +/** + * What a caller of a NATIVE fetch sees when the refusing proxy turns it away. That text is not the + * guard's: over https the runtime reports the proxy's 403 itself, and how it words it differs by + * platform and version (macOS bun 1.4.2 hands back the guard's own 403 response; CI's Linux runner + * throws "Proxy refused to open a tunnel: 403 Forbidden"). So the outcome is pinned only as far as + * the guard owns it: EITHER the guard's own 403, carrying its header, OR a throw — never a response + * the guard did not write. A call that had really been dialled ends in such a response (the red at + * the C7 base was "response 404 unguarded"). That the GUARD stopped it is then asserted on the + * guard's record, which its proxy writes only when the request was handed to it. + */ +const refusedByProxy = /^(response 403 refused|threw: .+)$/s; /** Each entry point, the call it plants, the outcome the caller sees and the target recorded. */ -const entryPoints: Array<[string, () => unknown, string, string]> = [ +const entryPoints: Array<[string, () => unknown, string | RegExp, string]> = [ [ "globalThis.fetch", () => fetch(`https://${FAR}/x`), @@ -189,7 +200,8 @@ describe("the test network guard refuses a host that is not loopback, through", test.each(entryPoints)("%s", async (_name, call, seen, target) => { const { outcome, refused } = await plant(call); - expect(outcome).toContain(seen); + // A string is matched as a substring, a pattern as itself. + expect(outcome).toMatch(seen); expect(refused).toEqual([target]); }); }); @@ -248,18 +260,21 @@ describe("the test network guard lets the suite reach what the suite owns", () = }); /** - * The database is the one far host a raw socket may reach, because it is deployment - * configuration rather than a vendor. `.invalid` never resolves, so the connection fails in DNS - * — and the point is that it got as far as DNS instead of being refused. + * The database is the one far NAME a raw socket may reach without its lookup being checked, + * because it is deployment configuration rather than a vendor. `home.arpa` (RFC 8375) never + * resolves in public DNS, so the connection fails in DNS — and the point is that it got as far as + * DNS instead of being refused. Not `.invalid`: that is a special-use name the guard lets through + * on its own terms, so a case aimed at one could not tell whether the database allowance exists. */ test("the configured database host, through a raw socket", async () => { const configured = process.env.TEST_DATABASE_URL; - process.env.TEST_DATABASE_URL = "postgres://u:p@db.openbot.invalid:5432/x"; + process.env.TEST_DATABASE_URL = + "postgres://u:p@db.openbot.home.arpa:5432/x"; try { const { outcome, refused } = await plant( () => new Promise((_resolve, reject) => { - net.connect(5432, "db.openbot.invalid").on("error", reject); + net.connect(5432, "db.openbot.home.arpa").on("error", reject); }), ); @@ -272,6 +287,141 @@ describe("the test network guard lets the suite reach what the suite owns", () = }); }); +/** + * A suite whose SUBJECT is the socket layer — `agent-computer`'s egress filter, which proves it + * pins the address it checked, falls back, and gives up on one that never answers — has to open + * sockets at addresses that are not loopback. The guard lets through only what cannot be a vendor: + * a literal address the public internet does not route, and a reserved special-use name (RFC 6761) + * ONLY when what it resolves to is such an address. Each allowance is planted next to the far + * address it must still refuse, so widening it into "anything goes" turns these red. + */ +describe("the test network guard judges a socket by the address it reaches", () => { + /** A `lookup` that answers one address, in both shapes the runtime asks for (Bun asks `all`). */ + function answering(address: string): net.LookupFunction { + return (( + _host: string, + options: { all?: boolean } | undefined, + callback: (...args: unknown[]) => void, + ) => + options?.all + ? callback(null, [{ address, family: 4 }]) + : callback(null, address, 4)) as unknown as net.LookupFunction; + } + + /** Settles on connect, on a short wait (an unroutable address may hang), or rejects on error. */ + function dial(open: () => net.Socket): Promise { + return new Promise((resolve, reject) => { + const socket = open(); + const timer = setTimeout(() => { + socket.destroy(); + resolve("pending"); + }, 200); + socket.once("connect", () => { + clearTimeout(timer); + const reached = `connected ${socket.remoteAddress}`; + socket.destroy(); + resolve(reached); + }); + socket.once("error", (error) => { + clearTimeout(timer); + socket.destroy(); + reject(error); + }); + }); + } + + test("an unroutable private address is let through; a public one and the metadata service are not", async () => { + const privately = await plant(() => + dial(() => net.connect({ host: "10.255.255.1", port: 9 })), + ); + expect(privately.outcome).not.toContain(refusedCall); + expect(privately.refused).toEqual([]); + + const publicly = await plant(() => + dial(() => net.connect({ host: "93.184.215.14", port: 80 })), + ); + expect(publicly.outcome).toContain(refusedCall); + expect(publicly.refused).toEqual(["socket 93.184.215.14"]); + + const metadata = await plant(() => + dial(() => net.connect({ host: "169.254.169.254", port: 80 })), + ); + expect(metadata.outcome).toContain(refusedCall); + expect(metadata.refused).toEqual(["socket 169.254.169.254"]); + }); + + test("a .test name is let through when its lookup answers loopback", async () => { + const listener = net.createServer((socket) => socket.end()); + await new Promise((resolve) => + listener.listen(0, "127.0.0.1", resolve), + ); + const { port } = listener.address() as net.AddressInfo; + try { + let reached = ""; + const { refused } = await plant(async () => { + reached = await dial(() => + net.connect({ + host: "pinned.example.test", + port, + lookup: answering("127.0.0.1"), + }), + ); + }); + + expect(reached).toBe("connected 127.0.0.1"); + expect(refused).toEqual([]); + } finally { + listener.close(); + } + }); + + /* + * A reserved name is only reserved in PUBLIC DNS: a `lookup` option (or /etc/hosts) can map it to + * any address at all, which is exactly how a test could smuggle a vendor's address past a + * name-based allowance. Planted through the socket and through a named `node:http` import, which + * the request-layer patch does not reach and which therefore meets only the socket guard. + */ + const smuggle = answering("93.184.215.14"); + + test.each<[string, () => Promise]>([ + [ + "net.connect", + () => + dial(() => + net.connect({ + host: "smuggled.example.test", + port: 80, + lookup: smuggle, + }), + ), + ], + [ + "a named import of http.request", + () => + new Promise((resolve, reject) => { + namedHttpRequest({ + host: "smuggled.example.test", + port: 80, + lookup: smuggle, + }) + .on("response", () => resolve("answered")) + .on("error", reject) + .end(); + }), + ], + ])( + "a .test name whose lookup answers a public address is refused, through %s", + async (_name, call) => { + const { outcome, refused } = await plant(call); + + expect(outcome).toContain(refusedCall); + expect(refused).toEqual([ + "socket smuggled.example.test resolved to 93.184.215.14", + ]); + }, + ); +}); + /** * The case the guard was written for, in the shape that defeated the per-file stub: the vendor * client is constructed with no stub in sight, and its own bound transport is the guard. From df1c682b9a8781f9ff9266b8d3973ad1feba16dc Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 18:17:55 +0200 Subject: [PATCH 219/232] fix(plugins): log an authorization mint whose outcome is unknown POST /servers/:id/authorize's uncertain branch fires when the vendor did not answer clearly: a dropped connection, an aborted socket, the vendor deadline, or the adapter's "answered with no id". It left the claim standing with a NULL auth_id, answered 502, and wrote nothing anywhere. A flow the vendor may have started was then invisible to anybody who could end it. The claim still stands, and the code now says why. Deleting the claim frees the actor to start a second flow beside one that may be live. Settling it is not possible: closeAuthorizationAttempt matches on auth_id, which is NULL here, and a settled row frees the actor the same way. The row cannot complete and holds the person for at most fifteen minutes, so the cost of keeping it is bounded. What changes is that the branch now leaves a trace. It logs `arcade-mint-outcome-unknown` with server, action, attemptStartedAt (the row's created_at, which locates the attempt) and the reason through reasonWithoutStatement. The line carries no authorization id, no url and no account or vendor user id. This is B6a's shape (0557dab6) one seam earlier. No audit event and no ledger change. The branch writes nothing to pending_authorizations, and the attempt's missing trail rows are already B5e debt under the claim and the expiry overwrite. The debt ceiling stays at 13. Call sites: no signature changed. Only the authorize route's catch is touched. Test: arcade-authorize-route.test.ts, three cases: the uncertain mint's line, the missing-id line, and no such line on a definite refusal. Co-Authored-By: Claude Opus 5.5 --- .../src/plugins/route-groups/connections.ts | 49 ++++++++++ server/tests/arcade-authorize-route.test.ts | 96 +++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts index 9812afc75..6fec1df46 100644 --- a/server/src/plugins/route-groups/connections.ts +++ b/server/src/plugins/route-groups/connections.ts @@ -1517,6 +1517,55 @@ export function mountConnectionRoutes({ refusal.status, ); } + /* + * THE UNCERTAIN MINT: THE CLAIM STANDS, AND IT DOES NOT STAND IN SILENCE. + * + * WHY THE CLAIM STANDS. Nobody can read the vendor's answer here, so nobody knows whether a + * flow exists at the vendor. Ending the attempt on that guess is the irreversible mistake in + * one direction: a deleted claim frees this actor to start a second flow beside one that may + * be live. A settled verdict is no better: `closeAuthorizationAttempt` matches on `auth_id`, + * which is NULL here, so there is no verdict this row could take, and a settled row frees the + * actor just as a deleted one does. Left standing with a NULL `auth_id`, the row can never + * complete and holds the actor until it is fifteen minutes old. That cost is bounded. A + * second live flow is not. + * + * WHY IT IS NOT LEFT SILENT. Before this line the branch wrote nothing anywhere. The row says + * "a claim with no mint", which is also what a mint still in flight looks like. The person got + * a 502 sentence and the deployment kept no trace. So a flow this deployment may have + * started, and will never see completed, was invisible to everybody who could end it. This + * is B6a's shape one seam earlier: `arcade-context-withdrawal-unknown` is the same unknown + * on the revoke, and it is answered the same way. + * + * WHAT AN OPERATOR CAN NOW SEE, AND DO WITH IT. One line, `arcade-mint-outcome-unknown`, + * naming the server, the action and `attemptStartedAt`. The attempt row is + * `pending_authorizations` at that `(server_id, action, created_at)`, holding NULL `auth_id`. + * The vendor-side flow, if one exists, is a pending authorization for this action on this + * account started at that instant. Nobody was given its url, so nobody can complete it. An + * operator who needs it gone ends it at the vendor; otherwise it lapses there and the claim + * lapses here. A run of these lines is the vendor not answering, not one person's problem. + * + * WHAT IS NOT IN THE LINE. No authorization id: none was read, and that is the point. No + * redirect url, the bearer capability; on the "answered with no id" path it existed and was + * dropped inside the adapter. No account or vendor user id, as on every sibling line. The + * reason goes through {@link reasonWithoutStatement}. Every error that reaches this branch + * carries this deployment's own sentence: `ArcadeFailureError` drops the vendor's prose by + * construction, the adapter's own refusals are ours, and {@link VendorTimeoutError} is ours. + * + * NOT AN AUDIT ROW. Nothing here writes to `pending_authorizations`, and the attempt's + * missing trail rows are already debt in `pending-authorization-audit-ledger.test.ts`, under + * the claim and the expiry overwrite (B5e). Filing an event for this one branch while the + * claim it follows files none would put half a lifecycle on the trail. + */ + console.error( + JSON.stringify({ + type: "arcade-mint-outcome-unknown", + server: serverId, + action, + attemptStartedAt: claim.attempt.createdAt, + note: "The vendor did not answer an authorization clearly, so whether it started a flow is unknown. The attempt was left standing with no authorization id: it cannot complete, it holds this person until it is fifteen minutes old, and nobody was sent to a consent screen. If the vendor shows a pending authorization for this action begun at attemptStartedAt, nobody here can finish it; end it there if it must not linger.", + error: reasonWithoutStatement(error), + }), + ); return context.json( { error: diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index 1ec318023..f07f692fb 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -145,6 +145,31 @@ const authorize = ( body: JSON.stringify(body), }); +/** + * Every line the deployment wrote while `work` ran, each as the text it would have printed. The + * arguments are joined rather than mapped through one `String()`, so an object logged beside a + * string still shows up as `[object Object]`, which is the shape a leak hides in. + */ +async function heard(work: () => T | Promise): Promise<[T, string[]]> { + const said: string[] = []; + const before = globalThis.console; + const hear = (...args: unknown[]) => + said.push(args.map((arg) => String(arg)).join(" ")); + globalThis.console = { + ...before, + log: hear, + info: hear, + warn: hear, + error: hear, + debug: hear, + } as typeof console; + try { + return [await work(), said]; + } finally { + globalThis.console = before; + } +} + const attemptsFor = (actorId: string) => database .select() @@ -476,6 +501,77 @@ describe("POST /servers/:id/authorize", () => { ); }); + /* + * L4-a: THE UNCERTAIN BRANCH LEAVES THE CLAIM STANDING, AND IT USED TO LEAVE NOTHING ELSE. + * + * The outcome assertions here already passed before the fix: the row stands with a NULL + * `auth_id` and holds the actor. What was missing is the trace. A flow the vendor may have + * started went unrecorded everywhere, so nobody who could end it at the vendor could find it. + */ + test("an uncertain mint leaves the claim standing AND leaves an operator a line naming it", async () => { + const actor = await person("uncertain-line"); + beforeMint = async () => { + throw new APIConnectionError({ message: "socket dropped" }); + }; + const [response, said] = await heard(() => + authorize(appAs("user", actor), personalApp), + ); + expect(response.status).toBe(502); + + // NOT ENDED ON A GUESS: the claim stands, with no id, holding the actor. + const [row] = await attemptsFor(actor); + expect(row).toMatchObject({ + authId: null, + completedAt: null, + outcome: null, + }); + + // AND SAID OUT LOUD, with what an operator needs to find the attempt and the flow. + const line = said.find((text) => + text.includes("arcade-mint-outcome-unknown"), + ); + expect(line).toBeDefined(); + const logged = JSON.parse(line ?? "{}") as Record; + expect(logged.server).toBe(personalApp); + expect(logged.action).toBe(ACTION); + expect(logged.attemptStartedAt).toBe(row?.createdAt.toISOString()); + expect(logged.error).toBe("socket dropped"); + // Nothing that is not ours: no bearer url, no vendor user id, no authorization id field. + expect(line).not.toContain("bearer-capability"); + expect(line).not.toContain(actor); + expect(Object.keys(logged)).not.toContain("authorizationId"); + }); + + test("the missing-id mint is uncertain and leaves the same line, carrying the adapter's sentence", async () => { + const actor = await person("noid-line"); + beforeMint = async () => { + throw new Error("Arcade answered an authorization with no id."); + }; + const [response, said] = await heard(() => + authorize(appAs("user", actor), personalApp), + ); + expect(response.status).toBe(502); + const line = said.find((text) => + text.includes("arcade-mint-outcome-unknown"), + ); + expect(line).toBeDefined(); + expect(line).toContain("Arcade answered an authorization with no id."); + expect(line).not.toContain("bearer-capability"); + }); + + test("a definite refusal is not an unknown outcome, so it leaves no such line", async () => { + const actor = await person("definite-no-line"); + beforeMint = refuse; + const [response, said] = await heard(() => + authorize(appAs("user", actor), personalApp), + ); + expect(response.status).toBe(502); + expect(await attemptsFor(actor)).toEqual([]); + expect( + said.filter((text) => text.includes("arcade-mint-outcome-unknown")), + ).toEqual([]); + }); + test("a non-SDK throw (the adapter's missing-id Error) is uncertain too: the attempt stands", async () => { const actor = await person("noid"); beforeMint = async () => { From 445259c7e319fc4e745bf098b99e4e18b8bd536d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 18:22:47 +0200 Subject: [PATCH 220/232] docs(accounts): say why a person's vendor id is their local id, and mint it in one place L1b-a adjudicated: the collapse in accountRefFor is correct for BOTH providers. Composio's and Arcade's user_id are both caller-chosen strings, not vendor-issued handles; every existing person connection was made under the local id (pre-#757 the Composio adapter took the local user id directly, and migration 0052 backfilled vendor_user_id = user_id); Arcade's return leg compares the settled userId to the attempt's stored vendor_user_id minted from this ref. No behaviour change. The decision was spelled twice (accountRefFor and the offboarding sweep); both now call personAccount() in shared-accounts.ts, which carries the reasoning so the next reader does not "fix" it. Call sites of personAccount: plugin-store/context.ts accountRefFor (person branch); plugin-store/connections.ts offboarding sweep. Rows read back keep going through asAccountRef with the row's own vendor_user_id. Co-Authored-By: Claude Opus 5.5 --- .../src/plugins/plugin-store/connections.ts | 8 ++--- server/src/plugins/plugin-store/context.ts | 7 ++++- server/src/plugins/shared-accounts.ts | 30 +++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/server/src/plugins/plugin-store/connections.ts b/server/src/plugins/plugin-store/connections.ts index cadf27f87..a3238577a 100644 --- a/server/src/plugins/plugin-store/connections.ts +++ b/server/src/plugins/plugin-store/connections.ts @@ -23,7 +23,7 @@ import { askAction as composioAskAction, VERSION_ARG, } from "../composio"; -import type { AccountRef } from "../shared-accounts"; +import { type AccountRef, personAccount } from "../shared-accounts"; import type { StoreContext } from "./context"; import { attemptNamed, @@ -3223,11 +3223,7 @@ export function connectionMethods(context: StoreContext) { * DISTINCT TOOLKITS this person holds rather than that number squared. Empty with no broker * configured, where nothing is asked of anybody and the `false` below is the whole truth. */ - const account: AccountRef = { - holder: "person", - userId, - vendorUserId: userId, - }; + const account = personAccount(userId); const authProviders = new Map>(); const endedBesides = new Map< string, diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index 2efe818d5..e0413bf24 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -32,6 +32,7 @@ import { type AccountMode, type AccountRef, accountFor, + personAccount, } from "../shared-accounts"; import { type BrokeredDisconnectReason, @@ -316,8 +317,12 @@ export function createStoreContext(options: PluginStoreOptions) { return { refusal: `${row.title} runs in the account of the person asking, and this run is not attributed to anybody.`, }; + /* + * The actor's id is both the local id and the vendor id, deliberately and for both + * providers. See {@link personAccount} for why, before changing it. + */ return { - ref: { holder: "person", userId: actorId, vendorUserId: actorId }, + ref: personAccount(actorId), title: row.title, mode: "personal", appId, diff --git a/server/src/plugins/shared-accounts.ts b/server/src/plugins/shared-accounts.ts index f4737a429..c68e33563 100644 --- a/server/src/plugins/shared-accounts.ts +++ b/server/src/plugins/shared-accounts.ts @@ -21,6 +21,36 @@ export type AccountRef = | { holder: "person"; userId: string; vendorUserId: string } | { holder: "deployment"; vendorUserId: string }; +/** + * A PERSON'S ACCOUNT, AND WHY ITS TWO IDS ARE ONE VALUE. + * + * `userId` and `vendorUserId` are different CONCEPTS — who the account belongs to here, and the + * name the vendor files it under — and they are kept as two fields because the deployment holder + * proves they can differ: its vendor id is minted by {@link mintDeploymentVendorUserId} and has no + * local user at all. FOR A PERSON THEY ARE THE SAME VALUE ON PURPOSE, for both providers: + * + * - Neither vendor issues a user handle. Composio's `user_id` and Arcade's `user_id` are both a + * string the CALLER chooses and the vendor files connections under, so "the vendor's id for this + * person" is whatever this deployment sends, and what it sends is the person's own id. + * - Every person connection that exists was made that way. Before shared accounts the Composio + * adapter was handed the local user id directly, and migration 0052 backfilled + * `vendor_user_id = user_id` for every person row it carried over. Sending anything else now + * would ask the vendor about an identity nobody connected under, and every existing connection + * would read as absent. + * - Arcade echoes the id back: a return leg's settled `userId` is compared to the attempt's stored + * `vendor_user_id`, which was minted from this ref. + * + * SO DO NOT "FIX" THE COLLAPSE BY DERIVING A DIFFERENT VENDOR ID HERE without migrating every + * stored person row and every vendor-side connection with it. And do not read the collapse as + * licence to use one field for the other: a store lookup by person matches `user_id`, a vendor call + * sends `vendorUserId`, and code that crosses them is only right by coincidence. This is the one + * place a person's ref is minted from a local id; a ref read back from a stored row goes through + * {@link asAccountRef} and carries the row's own `vendor_user_id`. + */ +export function personAccount(userId: string): AccountRef { + return { holder: "person", userId, vendorUserId: userId }; +} + export function asAccountMode(value: unknown): AccountMode | null { return value === "personal" || value === "shared" ? value : null; } From 180d2dc316f3c1b79218e534cf68ff59f5cd5d6d Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 18:25:29 +0200 Subject: [PATCH 221/232] fix(plugins): one judgement for what the trail says about a key app (K5: A5b, A5c, L5-a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retireConnectionsFor built its endedWith map with sibling rule 2 but not rule 1, so a KEY app whose own revoke answered false was filed as ended by an OAuth sibling's revoke (A5b). It and removeServer also passed the vendor's raw answer through as vendorRevocationRequested for a key app, where disconnectBrokered writes false (A5c, L5-a). Rows were right; the trail's account of why was wrong. The judgement now lives once, in plugin-store/context.ts: - consentRevokeReaches(app): rule 1's one spelling (kind !== "key"). - siblingsEndedBy({siblings, endedWith, revoked}): rules 1 and 2 as a judgement with no write; clearEndedSiblings adds rule 3 + the delete over its answer, retireConnectionsFor files endedWith from it. - vendorRevocationClaim(app): reads the kind before the revoke, returns (answered) => { vendorRevocationRequested } — the only computed writer. Call sites: - consentRevokeReaches: siblingsEndedBy, vendorRevocationClaim, clearWithdrawnAccount (asked app; previously its own kind comparison). - siblingsEndedBy: clearEndedSiblings, retireConnectionsFor. - vendorRevocationClaim: disconnectBrokered, removeServer, retireConnectionsFor. - clearEndedSiblings: signature unchanged; disconnectBrokered, clearWithdrawnAccount, removeServer. - disconnectBrokered's return value is unchanged ({ vendorRevocationRequested }). sole-definition: both KNOWN_SECOND_SPELLINGS debt entries removed (sibling-cascade-walk 2 sites -> 1, key-app-revoke-answer 3 -> 1), the key-app-revoke-answer home moved connections.ts -> context.ts, and the three new names tracked in the name table. Co-Authored-By: Claude Opus 5.5 --- .../src/plugins/plugin-store/connections.ts | 68 +++++++++++--- server/src/plugins/plugin-store/context.ts | 87 +++++++++++++++++- server/src/plugins/plugin-store/servers.ts | 20 +++- .../tests/arcade-sibling-collateral.test.ts | 92 +++++++++++++++++++ server/tests/sole-definition.test.ts | 64 +++++++------ 5 files changed, 284 insertions(+), 47 deletions(-) diff --git a/server/src/plugins/plugin-store/connections.ts b/server/src/plugins/plugin-store/connections.ts index cadf27f87..4f7a36601 100644 --- a/server/src/plugins/plugin-store/connections.ts +++ b/server/src/plugins/plugin-store/connections.ts @@ -61,6 +61,9 @@ export function connectionMethods(context: StoreContext) { brokerProvider, closeAuthorizationAttempt, resolveSiblings, + consentRevokeReaches, + vendorRevocationClaim, + siblingsEndedBy, clearEndedSiblings, } = context; return { @@ -2859,8 +2862,12 @@ export function connectionMethods(context: StoreContext) { * weakened the trail claim: a KEY sibling's row was deleted anyway, by the single OAuth * revoke that never ended it, leaving a live account at the vendor with no local row to * reach it. + * + * The claim is {@link vendorRevocationClaim}'s, which {@link removeServer} and + * {@link retireConnectionsFor} make for their own asked apps: this method was the one of the + * three that said `false` for a key, and now none of them says it for itself. */ - const askedIsKey = (await brokeredAppKind(input.toolkit)) === "key"; + const claim = await vendorRevocationClaim(input.toolkit); // Whether there was an account to end at all, which is what decides if anybody was // disconnected. Named apart from the field below because for a key the two differ: something @@ -2875,7 +2882,7 @@ export function connectionMethods(context: StoreContext) { toolkit: input.toolkit, }); - const vendorRevocationRequested = askedIsKey ? false : ended; + const asked = claim(ended); /* * THE ASKED APP FIRST, and on its own terms — this is the act, not a cascade of it. @@ -2912,7 +2919,7 @@ export function connectionMethods(context: StoreContext) { ? input.account.userId : "deployment", reason: input.reason, - vendorRevocationRequested, + ...asked, /* * HOW MANY apps on this identity could not be asked about and so were left out of * the set below, never their names. An integer, so an operator can see that "we did @@ -2935,7 +2942,7 @@ export function connectionMethods(context: StoreContext) { reason: input.reason, }); - return { vendorRevocationRequested }; + return asked; }, /** @@ -3004,8 +3011,11 @@ export function connectionMethods(context: StoreContext) { * it (rule 1); the caller watched that revoke answer true, which is the contract above and * is rule 2 discharged at the door; and a row that was not here had nothing to explain * (rule 3). The act itself is already recorded on the attempt row, as `withdrawn`. + * + * Rule 1 is asked of {@link consentRevokeReaches}, its one spelling, rather than of the kind + * directly — this was the one site that compared the kind for itself (round 2, K5). */ - if ((await brokeredAppKind(input.toolkit)) !== "key") { + if (await consentRevokeReaches(input.toolkit)) { const [deleted] = await database .delete(brokeredConnections) .where(accountRow(input.toolkit, input.account)) @@ -3245,6 +3255,24 @@ export function connectionMethods(context: StoreContext) { } } + /* + * WHAT THE TRAIL MAY CLAIM WAS ASKED OF THE VENDOR FOR EACH APP, read before a single revoke + * goes out, as {@link disconnectBrokered} reads its own. A KEY app's row says `false` whatever + * the vendor answered — see {@link vendorRevocationClaim}, which all three writers of an asked + * app's row now ask. This one used to pass the vendor's answer through for a key (round 2, + * L5-a), the same divergence {@link removeServer} had (A5c). + */ + const toRetire: { + toolkit: string; + claim: Awaited>; + }[] = []; + for (const connection of brokered) { + toRetire.push({ + toolkit: connection.toolkit, + claim: await vendorRevocationClaim(connection.toolkit), + }); + } + /* * What the broker was actually asked for each app, kept so the trail below records the answer * rather than the call. False where there is no broker at all: a deployment whose key has @@ -3264,12 +3292,17 @@ export function connectionMethods(context: StoreContext) { * asked — a later one is not punished for an earlier one — and the first refusal is rethrown * below, so the act still fails loudly and the administrator still gets a 500. */ - const withdrawn: { toolkit: string; requested: boolean }[] = []; + const withdrawn: { + toolkit: string; + requested: boolean; + claim: Awaited>; + }[] = []; const refusals: unknown[] = []; - for (const connection of brokered) { + for (const connection of toRetire) { try { withdrawn.push({ toolkit: connection.toolkit, + claim: connection.claim, requested: broker ? await broker.revoke({ /* @@ -3333,12 +3366,22 @@ export function connectionMethods(context: StoreContext) { * APPS THIS PERSON HOLDS ARE ALL THIS CAN SPEAK FOR. A sibling resolved on the same identity * but held by the deployment, or an app whose own revoke threw and so has no row here, is * left alone: this map only corrects what the loop below is already about to write. + * + * WHICH SIBLINGS A REVOKE ENDED IS {@link siblingsEndedBy}'S ANSWER, the one + * {@link clearEndedSiblings} deletes on — rules 1 and 2 — and not a walk of this method's + * own. It was one: rule 2 without rule 1, so a KEY app whose own ask answered false was filed + * as ended by an OAuth sibling's revoke that never reached it (round 2, A5b). What differs + * here is only what is DONE with the answer: this method deletes its own rows as its own acts + * above, so it takes the judgement without the delete and files `endedWith` from it. */ const endedWith = new Map(); for (const entry of withdrawn) { - if (!entry.requested) continue; - for (const app of endedBesides.get(entry.toolkit)?.siblings ?? []) { - if (app === entry.toolkit) continue; + const ended = await siblingsEndedBy({ + siblings: endedBesides.get(entry.toolkit)?.siblings ?? [], + endedWith: entry.toolkit, + revoked: entry.requested, + }); + for (const app of ended) { if (!endedWith.has(app)) endedWith.set(app, entry.toolkit); } } @@ -3379,8 +3422,11 @@ export function connectionMethods(context: StoreContext) { * made about the same grant. On Arcade the ask that ended this app may have been * spelled with a sibling's name, and `revoke`'s `false` here then means "already * gone", not "nobody asked" — `endedWith` beside it names the app that asked. + * + * AND NEVER FOR A KEY: {@link vendorRevocationClaim} makes the claim from that + * reading, and for a key app it is `false` whatever the vendor answered. */ - vendorRevocationRequested: connection.requested || Boolean(endedBy), + ...connection.claim(connection.requested || Boolean(endedBy)), /* * AND WHERE A SIBLING RELATIONSHIP IS BEING CLAIMED, HOW MANY apps on this identity * could not be asked about and so could not be ruled siblings either way — an diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts index 2efe818d5..9a9b25336 100644 --- a/server/src/plugins/plugin-store/context.ts +++ b/server/src/plugins/plugin-store/context.ts @@ -1784,6 +1784,78 @@ export function createStoreContext(options: PluginStoreOptions) { }; } + /** + * WHETHER A CONSENT REVOKE AT THE VENDOR REACHES THIS APP AT ALL — the one spelling of rule 1, + * which every judgement below about a KEY app asks rather than comparing a kind itself. + * + * A key is a credential of this deployment's own; no withdrawal at the vendor ends it. So a key + * app is never an app a revoke ENDED (its row stands, and no trail row names an ender for it), + * and never one whose trail row may say a vendor was ASKED to withdraw it. `!== "key"` keeps the + * reading every caller already made: an app whose scheme cannot be read, or that has no account, + * is reported as reached, which is the claim that cannot be too weak (see + * {@link disconnectBrokered}'s roster). + */ + async function consentRevokeReaches(app: string): Promise { + return (await brokeredAppKind(app)) !== "key"; + } + + /** + * WHAT THE TRAIL SAYS WAS ASKED OF THE VENDOR ABOUT THE APP AN ACT WAS ABOUT — the one spelling + * of `vendorRevocationRequested` that is computed rather than constant. + * + * `answered` is the caller's own reading of what the vendor said: {@link disconnectBrokered}'s + * `ended`, {@link removeServer}'s per-account answer, {@link retireConnectionsFor}'s own answer + * or-ed with "a sibling's ask ended it". For a KEY app that reading is not the claim — whatever + * the vendor answered, it did not withdraw a key it never held — so the field is `false`. Three + * writers decided that three ways (round 2, A5c and L5-a): only the first said `false`. + * + * IN TWO STEPS, READ THEN ANSWER: the app's kind is read when this is called and the claim is + * made when the vendor's answer is handed to what it returns. Every writer reads the kind BEFORE + * its revoke goes out — so a failed read leaves the act before anything was asked of the vendor — + * and that order is kept rather than moved behind the revoke for the helper's convenience. + * + * Answers the payload fragment rather than the boolean so that no writer computes the field + * itself: a writer spreads this, and the guard in `sole-definition.test.ts` holds that to one + * home. + */ + async function vendorRevocationClaim( + app: string, + ): Promise<(answered: boolean) => { vendorRevocationRequested: boolean }> { + const reaches = await consentRevokeReaches(app); + return (answered) => ({ vendorRevocationRequested: reaches && answered }); + } + + /** + * WHICH OF A REVOKE'S SIBLINGS THAT REVOKE ENDED — rules 1 and 2 of {@link clearEndedSiblings}, + * as a JUDGEMENT with no write attached, so that a caller who needs the answer without the delete + * asks the same question rather than walking the set itself. + * + * {@link clearEndedSiblings} deletes and files what this answers (and adds rule 3, which is about + * a row and so belongs to the delete). {@link retireConnectionsFor} needs only the answer: it has + * already revoked every app the person holds and deletes them as its own acts, and what it needs + * from the siblings is which of its rows to annotate with `endedWith`. It used to walk the set + * itself with rule 2 and without rule 1, and so filed a KEY app as ended by an OAuth sibling's + * revoke (round 2, A5b) — trail-only, which is why no audit of the rows caught it. + * + * Deduplicated, the asking app excluded, in {@link resolveSiblings}' order. + */ + async function siblingsEndedBy(input: { + siblings: readonly string[]; + /** The app whose revoke this is. Never in the answer — it has its own row. */ + endedWith: string; + /** What that revoke ANSWERED. Rule 2. */ + revoked: boolean; + }): Promise { + if (!input.revoked) return []; + const ended: string[] = []; + for (const app of new Set(input.siblings)) { + if (app === input.endedWith) continue; + if (!(await consentRevokeReaches(app))) continue; + ended.push(app); + } + return ended; + } + /** * THE LOCAL ROWS ONE REVOKE'S CASCADE ENDS, AND THE THREE RULES THAT DECIDE WHICH — in ONE place, * because they were in two and the two disagreed about all three. @@ -1821,7 +1893,10 @@ export function createStoreContext(options: PluginStoreOptions) { * {@link disconnectBrokered}, {@link clearWithdrawnAccount} and {@link removeServer} — and all * three call this. {@link removeServer}'s loop was the oldest spelling and kept only 2 and 3 for * itself, deleting a KEY sibling's row on a removal; routing it here closed that (round 2, A5a). - * A fourth cascade belongs here too, not beside it. + * A fourth cascade belongs here too, not beside it. Rules 1 and 2 themselves are + * {@link siblingsEndedBy}'s, which this asks and which {@link retireConnectionsFor} asks for the + * one thing it needs without the delete — the `endedWith` it files — so the judgement about which + * siblings a revoke ended has one home whether or not a row is deleted on it (round 2, K5). * * `vendorRevocationRequested` IS TRUE ON EVERY ROW THIS WRITES AND IS NOT A GUESS: rule 2 says a * revoke answered true, and that ask ended the siblings with the app it named. `false` means "no @@ -1852,11 +1927,10 @@ export function createStoreContext(options: PluginStoreOptions) { */ reason: BrokeredDisconnectReason; }): Promise { - if (!input.revoked) return []; + // Rules 1 and 2, and the dedupe, are {@link siblingsEndedBy}'s; rule 3 is the delete's. + const ended = await siblingsEndedBy(input); const cleared: string[] = []; - for (const app of new Set(input.siblings)) { - if (app === input.endedWith) continue; - if ((await brokeredAppKind(app)) === "key") continue; + for (const app of ended) { // `returning` because whether a row was here is the whole of rule 3, and a delete that // answered nothing would leave the two cases indistinguishable. const [deleted] = await database @@ -1928,6 +2002,9 @@ export function createStoreContext(options: PluginStoreOptions) { brokerProvider, closeAuthorizationAttempt, resolveSiblings, + consentRevokeReaches, + vendorRevocationClaim, + siblingsEndedBy, clearEndedSiblings, }; } diff --git a/server/src/plugins/plugin-store/servers.ts b/server/src/plugins/plugin-store/servers.ts index 17f767a40..537bf1d18 100644 --- a/server/src/plugins/plugin-store/servers.ts +++ b/server/src/plugins/plugin-store/servers.ts @@ -70,6 +70,7 @@ export function serverMethods(context: StoreContext) { brokeredAppScheme, brokerProvider, resolveSiblings, + vendorRevocationClaim, clearEndedSiblings, } = context; return { @@ -1202,7 +1203,12 @@ export function serverMethods(context: StoreContext) { * key has since been unset can still remove the app, and it could not have been calling it * either way — but nothing was asked of Composio and the row must not claim otherwise. */ - const vendorRevocationRequested: boolean[] = []; + const revokeAnswers: boolean[] = []; + /* + * What the trail may CLAIM from those answers about this app, read before anything is + * revoked, as {@link disconnectBrokered} reads its own; see {@link vendorRevocationClaim}. + */ + const claim = await vendorRevocationClaim(toolkit); /* * THE APPS EACH ACCOUNT'S REVOCATION ENDS BESIDES THIS ONE, read BEFORE anything is revoked * and through {@link resolveSiblings}, the one place that answers it. Where a connection is @@ -1239,7 +1245,7 @@ export function serverMethods(context: StoreContext) { ? await resolveSiblings({ toolkit, account }, authProviders) : { siblings: [], unanswered: 0 }, ); - vendorRevocationRequested.push( + revokeAnswers.push( rowBroker ? await rowBroker.revoke({ account, toolkit }) : false, ); } @@ -1298,9 +1304,13 @@ export function serverMethods(context: StoreContext) { * outlives it somewhere else, so a constant here would be worse than none. It says * "requested" because that is the strongest thing the vendor's answer supports: * the upstream withdrawal runs as a background job nothing here can poll. + * + * AND NEVER FOR A KEY: the claim is {@link vendorRevocationClaim}'s, the one + * {@link disconnectBrokered} and {@link retireConnectionsFor} make, and for a key app + * it is `false` whatever the vendor answered. This row used to pass the raw answer + * through for a key (round 2, A5c). */ - vendorRevocationRequested: - vendorRevocationRequested[index] ?? false, + ...claim(revokeAnswers[index] ?? false), }, }); } @@ -1333,7 +1343,7 @@ export function serverMethods(context: StoreContext) { siblings, account, endedWith: toolkit, - revoked: vendorRevocationRequested[index] ?? false, + revoked: revokeAnswers[index] ?? false, unanswered, by, reason: "mcp_server_removed", diff --git a/server/tests/arcade-sibling-collateral.test.ts b/server/tests/arcade-sibling-collateral.test.ts index c8a6f9149..81e82e1a9 100644 --- a/server/tests/arcade-sibling-collateral.test.ts +++ b/server/tests/arcade-sibling-collateral.test.ts @@ -428,3 +428,95 @@ describe("offboarding a person", () => { }); }); }); + +/* + * WHAT THE TRAIL SAYS ABOUT A KEY APP, on the two acts that used to decide it for themselves (round + * 2, K5: A5b, A5c, L5-a). A key is a credential this deployment holds; no consent revoke at the + * vendor reaches it, so no `mcp.account_disconnected` row may say a vendor was asked to withdraw + * one — the reading `disconnectBrokered` already writes — nor that an OAuth sibling's revoke ended + * it. The ROWS were already right on both acts; these hold what the trail says about why. + */ +describe("the trail about a KEY app", () => { + /** A store over `revoke`, otherwise the file's own broker. */ + const storeWith = (revoke: ConnectedAppBroker["revoke"]) => + createPluginStore({ + brokerProvider: "arcade", + database, + auditStore: createAuditStore(database), + credentials: {} as never, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce" as const, deny: [], allow: ["true"] }), + broker: { ...broker, revoke }, + deploymentId: "acme", + sharedUse: async () => ({ allowed: true }), + }); + /** The one trail row for `app`, or a failure naming what is missing. */ + async function onlyRowFor(app: string): Promise> { + const rows = await trailFor([app]); + if (rows.length !== 1 || !rows[0]) { + throw new Error( + `expected one trail row for ${app}, found ${rows.length}`, + ); + } + return rows[0].payload; + } + + test("offboarding: an OAuth sibling's revoke is not named as what ended a KEY app", async () => { + const t = run(); + await server(t.gmail, "personal"); + await server(t.calendar, "personal", "API_KEY"); + await hold(t.gmail, t.person); + await hold(t.calendar, t.person); + // Arcade's shape: Gmail is asked first, ends the Google connection and answers true; the KEY + // app's own ask then finds nothing and answers false. + const ended = new Set(); + const arcadeStore = storeWith(async ({ account, toolkit }) => { + const connection = `${account.vendorUserId}:${providerOf(toolkit)}`; + if (ended.has(connection)) return false; + ended.add(connection); + return true; + }); + + await arcadeStore.retireConnectionsFor(t.person, "admin"); + + expect(await onlyRowFor(t.calendar)).toEqual({ + actor: "admin", + server: t.calendar, + owner: t.person, + reason: "person_removed", + vendorRevocationRequested: false, + }); + }); + + test("offboarding: a KEY app's own revoke answering true is not filed as a vendor withdrawal", async () => { + const t = run(); + await server(t.calendar, "personal", "API_KEY"); + await hold(t.calendar, t.person); + + await storeWith(async () => true).retireConnectionsFor(t.person, "admin"); + + expect(await onlyRowFor(t.calendar)).toEqual({ + actor: "admin", + server: t.calendar, + owner: t.person, + reason: "person_removed", + vendorRevocationRequested: false, + }); + }); + + test("removing a KEY app: its own revoke answering true is not filed as a vendor withdrawal", async () => { + const t = run(); + await server(t.calendar, "personal", "API_KEY"); + await hold(t.calendar, t.person); + + await storeWith(async () => true).removeServer(t.calendar, "admin"); + + expect(await onlyRowFor(t.calendar)).toEqual({ + actor: "admin", + server: t.calendar, + owner: t.person, + reason: "mcp_server_removed", + vendorRevocationRequested: false, + }); + }); +}); diff --git a/server/tests/sole-definition.test.ts b/server/tests/sole-definition.test.ts index b47b65aca..14f73724e 100644 --- a/server/tests/sole-definition.test.ts +++ b/server/tests/sole-definition.test.ts @@ -106,6 +106,25 @@ const TRACKED: Tracked[] = [ names: ["clearEndedSiblings"], home: "server/src/plugins/plugin-store/context.ts", }, + /* + * Rule 1's one spelling, the sibling judgement without the delete, and the asked app's trail + * claim — the three K5 made single-source so the trail and the deletion judge a key app once. + */ + { + decision: "consent-revoke-reaches", + names: ["consentRevokeReaches"], + home: "server/src/plugins/plugin-store/context.ts", + }, + { + decision: "siblings-ended-by", + names: ["siblingsEndedBy"], + home: "server/src/plugins/plugin-store/context.ts", + }, + { + decision: "vendor-revocation-claim", + names: ["vendorRevocationClaim"], + home: "server/src/plugins/plugin-store/context.ts", + }, /* Which apps a revoke ends besides the one it names: "the one place that answers it". */ { decision: "sibling-resolution", @@ -265,11 +284,11 @@ test("a definition is matched, and a call or a mention is not", () => { * once per logical line that spells the decision — against either the home or the debt on record. * * WHAT A PATH KEY CANNOT SAY. Two spellings in one file are that path listed twice; which of the two - * is the home is not something a path can carry. For A5c the second spelling is in the SAME file as + * is the home is not something a path can carry. For A5c the second spelling was in the SAME file as * the first (`plugin-store/connections.ts`; both A5b and A5c were in `plugins/store.ts` before #784 - * split it, and A5b's two now sit in `context.ts` and `connections.ts`), so the guard holds the - * COUNT: a third spelling anywhere, - * that file included, goes red; collapsing one goes red until the list drops a line. Moving the + * split it; K5 collapsed both into `context.ts`), so the guard holds the COUNT: a second spelling + * anywhere, the home's own file included, goes red; collapsing one goes red until the list drops a + * line. Moving the * spelling from the owning function into a sibling function of the same file does not. That is the * price of not going red on a rewrapped comment, and the same price the name table already pays. */ @@ -323,8 +342,10 @@ const SPELLED: Spelled[] = [ /* * THE SIBLING-CASCADE RULES: which of a revoke's siblings it ended. Spelled as a walk over a * {@link resolveSiblings} answer, because every spelling of the three rules is one — rule 1 and - * rule 3 are each a `continue` inside that walk, rule 2 the guard before it. `clearEndedSiblings` - * is the one walk; a site that needs the rules calls it rather than walking the set itself. + * rule 3 are each a `continue` inside that walk, rule 2 the guard before it. `siblingsEndedBy` + * is the one walk (rules 1 and 2); `clearEndedSiblings` adds rule 3 and the delete over its + * answer, and `retireConnectionsFor` files `endedWith` from the same answer (K5). A site that + * needs the rules asks one of those rather than walking the set itself. */ { decision: "sibling-cascade-walk", @@ -339,7 +360,9 @@ const SPELLED: Spelled[] = [ /* * WHAT THE TRAIL SAYS WAS ASKED OF THE VENDOR, for the app an act was ABOUT. A key app's answer * is `false` — nothing was asked of any vendor about a credential this deployment holds — and - * `disconnectBrokered` spells that once, as `askedIsKey ? false : ended`. A writer that computes + * `vendorRevocationClaim` spells that once, as a payload fragment every writer of an asked app's + * row spreads (`disconnectBrokered`, `removeServer`, `retireConnectionsFor`; K5 moved the home + * here from `disconnectBrokered`'s `askedIsKey ? false : ended`). A writer that computes * the field from anything else is a second answer to the same question. A constant `true` or * `false` is not a computation and is not matched: `clearEndedSiblings` writes `true` on rows * rule 1 has already kept key apps out of, and the vault loops write `false` about secrets. @@ -349,7 +372,7 @@ const SPELLED: Spelled[] = [ patterns: [ /\bvendorRevocationRequested\s*[:=]\s*(?!true\b|false\b|boolean\b)\S/, ], - home: ["server/src/plugins/plugin-store/connections.ts"], + home: ["server/src/plugins/plugin-store/context.ts"], }, ]; @@ -365,24 +388,13 @@ const KNOWN_SECOND_SPELLINGS: Record< string, { finding: string; why: string; sites: string[] } > = { - "sibling-cascade-walk": { - finding: "A5b (round 2, wave 3) — open", - why: "`retireConnectionsFor` builds its `endedWith` map by walking the siblings itself, with rule 2 (`if (!entry.requested) continue`) and without rule 1, so a key app's trail row can name an OAuth revoke as what ended it. Its shape is an annotation, not a delete, which is why it was not routed through `clearEndedSiblings` with the other three.", - sites: [ - "server/src/plugins/plugin-store/context.ts", - "server/src/plugins/plugin-store/connections.ts", - ], - }, - "key-app-revoke-answer": { - finding: - "A5c (round 2, wave 3) — open; the `retireConnectionsFor` line is the same divergence, found by L5", - why: "`removeServer` writes `vendorRevocationRequested[index] ?? false` — the vendor's raw answer — for the removed app even when it is a key app, and `retireConnectionsFor` writes `connection.requested || Boolean(endedBy)`, also raw for a key app; `disconnectBrokered` writes `false` for a key. One field, three answers.", - sites: [ - "server/src/plugins/plugin-store/connections.ts", - "server/src/plugins/plugin-store/connections.ts", - "server/src/plugins/plugin-store/servers.ts", - ], - }, + /* + * EMPTY SINCE ROUND 2, K5. Both entries L5 landed with were collapsed into one home each in + * `plugin-store/context.ts`: "sibling-cascade-walk" (A5b — `retireConnectionsFor`'s own walk, + * without rule 1) now asks `siblingsEndedBy`, and "key-app-revoke-answer" (A5c, L5-a — + * `removeServer` and `retireConnectionsFor` passing a key app's raw vendor answer through) now + * asks `vendorRevocationClaim`, as `disconnectBrokered` does. A new entry here is a new defect. + */ }; /** Every source file in which one of `patterns` matches a logical line, comments stripped. */ From 1d4e7793fbc3b819ff4158791a61ca218c969c98 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 18:26:41 +0200 Subject: [PATCH 222/232] fix(L4-b): an attempt dies only with the person who started it `0060` (wave 1's B7) keyed both `pending_authorizations.actor_id` and `.user_id` to `users.id` and cascaded both. Nothing in the database made the two columns equal, so a row could name two people and die with EITHER: deleting person B removed an attempt person A had started. Observed against the base schema (test below): A's attempt, row `actor_id = A, user_id = B`, gone after `DELETE FROM users WHERE id = B` -- "Expected: 1, Received: 0". WHAT `user_id` HOLDS. Whose account the attempt is for. The row is the actor's (primary key, session subject the verifier matches). The only writer, `claimAuthorizationAttempt`, copies `account.userId` from `accountRefFor`, which answers a Personal app with `{ holder: "person", userId: actorId }` and a Shared app with `holder: "deployment"`, user_id NULL. So in every row the application writes, `user_id` is NULL or the actor. The cross-actor row was unreachable through the code, but admitted by the schema -- and the cascade made it lossy. DECISION: make the equality the database's, and drop `user_id`'s own key. - New CHECK `pending_authorizations_user_is_actor_check`: `user_id IS NULL OR user_id = actor_id`. - `pending_authorizations_user_id_users_id_fk` dropped, with `pending_authorizations_user_idx` (it existed only to serve that key; nothing reads by user_id). B7's reasoning for CASCADE on `actor_id` stands unchanged: in-flight state, no grant, audit trail in keyless `audit_events`, RESTRICT would block a legitimate deletion. It does not transfer to `user_id` as a SEPARATE key, because that key's only distinct effect was to reach rows a different person started. Through the CHECK `user_id` names a person exactly when `actor_id` does, so the "user_id that is not a person is refused" guarantee B7 added is kept (now refused by the CHECK). MIGRATION 0062_pending_authorization_user_is_actor, generated with `db:generate` (snapshot not hand-edited); `drizzle-kit check` clean, a second `generate` reports no changes. Applied to a FRESH database twice (local server, and a fresh pgvector/pgvector:pg17 container, the CI image): 63 migrations, exit 0, constraints as above. No purge before the CHECK, unlike 0060: every row the app writes already satisfies it, and deleting a violating hand-written row would be the loss this fixes -- a violating row makes the migration refuse loudly instead. LEDGER. `pending-authorization-audit-ledger.test.ts`: the `user_id` cascade row (debt B5e) is REMOVED because its key is dropped (the scan sees the DROP CONSTRAINT), with a comment recording it, and DEBT_CEILING lowered 13 -> 12. Not relabelled. TESTS. `arcade-attempt-constraints.test.ts`: - new: deleting one person never takes an attempt a different person started (branch-free: try the two-person row, give the actor their own row ON CONFLICT DO NOTHING, delete the other person, count) -- red on base schema "Expected: 1 Received: 0", green after. - new: a person attempt for somebody other than its actor is refused, by name. - the `attempt()` fixture defaulted every row to one shared `user-${suite}` beside a fresh actor -- the very two-person shape; it now defaults `userId` to the actor. - "a user_id that is not a person is refused" now expects the CHECK's name. - the FK-index test reads the keys from `pg_constraint` and pins them ({actor_id, server_id}). Planted: CHECK replaced with `CHECK (true)` under the same name -> both refusal cases red ("Received: accepted"); restored. Comment-only updates in six arcade test files that said `user_id` keys to `users` and cascades. CALL SITES. No production code path changed; schema only. Writers of the table: `claimAuthorizationAttempt` (sole INSERT, already writes user_id = actor or NULL; its ON CONFLICT re-claim keeps the same actor). Other store writes (`recordAuthorizationMint`, `discardAuthorizationAttempt`, `completeAuthorizationAttempt`, `closeAuthorizationAttempt` and its three wrappers) never write `user_id`. Did not touch `arcade-revoke-invariant.test.ts` (7/0), L4-a, or the fail-open read class. Co-Authored-By: Claude Opus 5.5 --- ...62_pending_authorization_user_is_actor.sql | 21 + server/drizzle/meta/0062_snapshot.json | 9997 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 41 +- .../tests/arcade-attempt-constraints.test.ts | 134 +- server/tests/arcade-attempt-schema.test.ts | 4 +- .../arcade-attempt-store.integration.test.ts | 2 +- server/tests/arcade-authorize-cancel.test.ts | 4 +- server/tests/arcade-authorize-route.test.ts | 4 +- ...completion-transaction.integration.test.ts | 4 +- .../tests/arcade-journey.integration.test.ts | 2 +- ...pending-authorization-audit-ledger.test.ts | 12 +- 12 files changed, 10184 insertions(+), 48 deletions(-) create mode 100644 server/drizzle/0062_pending_authorization_user_is_actor.sql create mode 100644 server/drizzle/meta/0062_snapshot.json diff --git a/server/drizzle/0062_pending_authorization_user_is_actor.sql b/server/drizzle/0062_pending_authorization_user_is_actor.sql new file mode 100644 index 000000000..a682698b6 --- /dev/null +++ b/server/drizzle/0062_pending_authorization_user_is_actor.sql @@ -0,0 +1,21 @@ +-- An attempt belongs to the person who started it, and only that person's deletion may take it. +-- +-- `0060` keyed both `actor_id` and `user_id` to `users` and cascaded both. `actor_id` is the row's +-- owner (the primary key, the session subject the verifier matches). `user_id` is whose account the +-- attempt is for, which for a `person` attempt is always the actor — `accountRefFor` answers a +-- Personal app with `{ userId: actorId }` and the only writer copies that — but nothing in the +-- database said so. A row naming two people therefore died with EITHER of them: deleting one person +-- destroyed an attempt a DIFFERENT person had started and was consenting through. +-- +-- So the equality becomes a CHECK, and `user_id` loses its own key and the index that served it. +-- Through the CHECK, `user_id` names a person exactly when `actor_id` does, and `actor_id`'s cascade +-- (B7's reasoning, unchanged) is the only one left that reaches this table from `users`. +-- +-- NO PURGE BEFORE THE CHECK, unlike 0060's orphans. Every row the application writes already holds +-- the equality, so a violating row was written by hand; deleting it would be the very loss this +-- migration exists to stop. If one exists, the ADD CONSTRAINT refuses and names the constraint, and +-- an operator decides what that row was. +ALTER TABLE "pending_authorizations" DROP CONSTRAINT "pending_authorizations_user_id_users_id_fk"; +--> statement-breakpoint +DROP INDEX "pending_authorizations_user_idx";--> statement-breakpoint +ALTER TABLE "pending_authorizations" ADD CONSTRAINT "pending_authorizations_user_is_actor_check" CHECK ("pending_authorizations"."user_id" IS NULL OR "pending_authorizations"."user_id" = "pending_authorizations"."actor_id"); diff --git a/server/drizzle/meta/0062_snapshot.json b/server/drizzle/meta/0062_snapshot.json new file mode 100644 index 000000000..82e9e0759 --- /dev/null +++ b/server/drizzle/meta/0062_snapshot.json @@ -0,0 +1,9997 @@ +{ + "id": "715bfd83-d33c-4c64-8233-060458d32510", + "prevId": "bc785e20-49f3-4886-b0b1-a81186dcbe0c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "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": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_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": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "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": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "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": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "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": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_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": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_authorizations": { + "name": "pending_authorizations", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_id": { + "name": "auth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_authorizations_server_idx": { + "name": "pending_authorizations_server_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_authorizations_actor_id_users_id_fk": { + "name": "pending_authorizations_actor_id_users_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_authorizations_server_id_mcp_servers_id_fk": { + "name": "pending_authorizations_server_id_mcp_servers_id_fk", + "tableFrom": "pending_authorizations", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_authorizations_holder_check": { + "name": "pending_authorizations_holder_check", + "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)" + }, + "pending_authorizations_outcome_check": { + "name": "pending_authorizations_outcome_check", + "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')" + }, + "pending_authorizations_completion_check": { + "name": "pending_authorizations_completion_check", + "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)" + }, + "pending_authorizations_user_is_actor_check": { + "name": "pending_authorizations_user_is_actor_check", + "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\"" + } + }, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "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": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "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": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "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": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "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": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_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": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_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": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_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": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_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": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 34f6c8129..29330e616 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -435,6 +435,13 @@ "when": 1791943598343, "tag": "0061_settle_brokered_account_mode", "breakpoints": true + }, + { + "idx": 62, + "version": "7", + "when": 1791994602603, + "tag": "0062_pending_authorization_user_is_actor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts index 8f1200450..6574e04ef 100644 --- a/server/src/db/schema/plugins.ts +++ b/server/src/db/schema/plugins.ts @@ -461,10 +461,20 @@ export const brokeredConnections = pgTable( * setting it, and a second return finds it set. `auth_id` is what binds a callback to this attempt; * an attempt holding null can never complete. * - * BOTH COLUMNS THAT NAME A PERSON CARRY THE FOREIGN KEY, AND BOTH CASCADE. `actor_id` and - * `user_id` are `users.id`, and a column that names a table without a key is a column the database - * will let drift: a deleted person left attempt rows pointing at nobody, and the verifier's - * "find the attempt for this session" would then be matching a key no session can ever present. + * THE ROW IS THE ACTOR'S, AND ONLY `actor_id` CARRIES THE KEY TO `users`. `actor_id` is `users.id`, + * and a column that names a table without a key is a column the database will let drift: a deleted + * person left attempt rows pointing at nobody, and the verifier's "find the attempt for this + * session" would then be matching a key no session can ever present. + * + * `user_id` IS NOT A SECOND PERSON. It is whose account the attempt is for, and for a `person` + * attempt that is the actor and nobody else — `accountRefFor` answers a Personal app with + * `{ userId: actorId }`, and nothing lets one person consent into another's account. So the database + * holds `user_id IS NULL OR user_id = actor_id` (`pending_authorizations_user_is_actor_check`), and + * through that CHECK `user_id` names a person exactly when `actor_id` does. It carried a cascading + * key of its own from `0060` to `0062`, and with nothing making the two columns equal that key let a + * row name two people and die with EITHER: deleting one person took an attempt a DIFFERENT person + * had started. An attempt is evidence of somebody's consent in progress, and the one person whose + * deletion may end it is the one who started it. * * CASCADE RATHER THAN RESTRICT, AND THIS ROW IS NOT THE AUDIT TRAIL. An attempt is in-flight * working state with a fifteen-minute life and at most one row per actor, holding no grant and no @@ -478,7 +488,8 @@ export const brokeredConnections = pgTable( * * `server_id`'s key gets an index of its own. A foreign key is not indexed by postgres on the * referencing side, so deleting one `mcp_servers` row made the cascade scan this table, and so did - * every lookup by server. `user_id` gets one for the same reason, now that it too is a key. + * every lookup by server. `actor_id` is the primary key and indexed by it; `user_id`, no longer a + * key and read by nothing, has none. */ export const pendingAuthorizations = pgTable( "pending_authorizations", @@ -496,9 +507,8 @@ export const pendingAuthorizations = pgTable( * at runtime and the row this attempt is reconciled into is held to that pair. */ holder: text("holder").$type<"person" | "deployment">().notNull(), - userId: text("user_id").references(() => users.id, { - onDelete: "cascade", - }), + /** Always the actor or `null`; see `pending_authorizations_user_is_actor_check` below. */ + userId: text("user_id"), vendorUserId: text("vendor_user_id").notNull(), authId: text("auth_id"), createdAt: createdAt(), @@ -547,8 +557,21 @@ export const pendingAuthorizations = pgTable( "pending_authorizations_completion_check", sql`(${table.completedAt} IS NULL) = (${table.outcome} IS NULL)`, ), + /** + * A `person` attempt is for the actor's own account, so the row names one person, not two. + * + * HERE AND NOT ONLY IN `accountRefFor`, because this is what keeps a cascade on `users` from + * reaching a row somebody else started — the guarantee is the database's or it is nobody's: a + * hand-run INSERT, a restore, or the next writer does not pass through `accountRefFor`. The + * `IS NULL` arm is the `deployment` attempt, whose shape `pending_authorizations_holder_check` + * already decides; spelled out so this CHECK says what it admits rather than leaning on a NULL + * comparison passing. + */ + check( + "pending_authorizations_user_is_actor_check", + sql`${table.userId} IS NULL OR ${table.userId} = ${table.actorId}`, + ), index("pending_authorizations_server_idx").on(table.serverId), - index("pending_authorizations_user_idx").on(table.userId), ], ); diff --git a/server/tests/arcade-attempt-constraints.test.ts b/server/tests/arcade-attempt-constraints.test.ts index 189577198..e5fa4633a 100644 --- a/server/tests/arcade-attempt-constraints.test.ts +++ b/server/tests/arcade-attempt-constraints.test.ts @@ -18,23 +18,35 @@ const database = createDatabase(testDatabaseUrl(), TEST_POOL); const suite = randomUUID().slice(0, 8); const serverId = `arcade-constraints-${suite}`; -/** An attempt row with every column legal, so each test varies exactly the one it is about. */ -const attempt = (overrides: Record) => ({ - actorId: `actor-${randomUUID().slice(0, 8)}`, - serverId, - action: "connect", - holder: "person" as const, - userId: `user-${suite}`, - vendorUserId: `v-${suite}`, - ...overrides, -}); +/** + * An attempt row with every column legal, so each test varies exactly the one it is about. + * + * `userId` defaults to the actor, because a `person` attempt is for the actor's own account and the + * database holds that (`pending_authorizations_user_is_actor_check`). This fixture used to give + * every row one shared `user-${suite}` beside a fresh actor — a two-person row production never + * writes, and exactly the shape that let one person's deletion take another person's attempt. + */ +const attempt = (overrides: Record) => { + const actorId = + (overrides.actorId as string | undefined) ?? + `actor-${randomUUID().slice(0, 8)}`; + return { + actorId, + serverId, + action: "connect", + holder: "person" as const, + userId: actorId, + vendorUserId: `v-${suite}`, + ...overrides, + }; +}; /** * Every person this file invented, so `afterAll` can take them away again. * - * `actor_id` and `user_id` both key to `users` and both cascade, so the row a case is about cannot - * be written until its person exists — and deleting the person afterwards is also what deletes the - * attempt. Seeding happens inside {@link insert} rather than in `beforeAll` because the actor id is + * `actor_id` keys to `users` and cascades (and `user_id`, when set, must equal it), so the row a + * case is about cannot be written until its person exists — and deleting the person afterwards is + * also what deletes the attempt. Seeding happens inside {@link insert} rather than in `beforeAll` because the actor id is * generated per row, and a case that forgot to seed its own would otherwise be refused by the * foreign key and read as a CHECK that did not fire. */ @@ -175,7 +187,9 @@ describe("pending_authorizations holds its declared unions", () => { test("a deployment attempt carrying a user id is refused", async () => { expect( await refusedBy(() => - insert({ holder: "deployment", userId: `user-${suite}` }), + // The actor's own id, so the user-is-actor CHECK is satisfied and only the holder pair can + // be what refuses. + insert({ holder: "deployment" }), ), ).toBe("pending_authorizations_holder_check"); }); @@ -203,7 +217,7 @@ describe("pending_authorizations holds its declared unions", () => { test("the legal shapes are all still accepted", async () => { const action = `legal-${suite}`; - await insert({ action, holder: "person", userId: `user-${suite}` }); + await insert({ action, holder: "person" }); await insert({ action, holder: "deployment", userId: null }); // Each ended shape carries its `completed_at`, because the pair is now held by a CHECK: an // outcome on its own is no longer one of the legal shapes, and this list says which are. @@ -251,7 +265,9 @@ describe("pending_authorizations keys the people it names", () => { test("a user_id that is not a person is refused", async () => { // The actor IS a person here, so only the second column can be what refuses: a case that left - // both unseeded would pass on the actor key alone and say nothing about `user_id`. + // both unseeded would pass on the actor key alone and say nothing about `user_id`. Since `0062` + // what refuses it is the user-is-actor CHECK, not a key of its own: `user_id` names a person + // exactly when it names the actor, who is one. const actorId = `keyed-actor-${randomUUID().slice(0, 8)}`; await seedPeople(database, [actorId]); seeded.add(actorId); @@ -260,11 +276,11 @@ describe("pending_authorizations keys the people it names", () => { attempt({ actorId, userId: `ghost-owner-${randomUUID().slice(0, 8)}`, - // biome-ignore lint/suspicious/noExplicitAny: an id past the key is exactly what the FK is for. + // biome-ignore lint/suspicious/noExplicitAny: an id that names nobody is exactly what is being asked about. }) as any, ), ); - expect(refusal).toBe("pending_authorizations_user_id_users_id_fk"); + expect(refusal).toBe("pending_authorizations_user_is_actor_check"); }); test("deleting the person takes their in-flight attempt with them", async () => { @@ -291,8 +307,21 @@ describe("pending_authorizations keys the people it names", () => { * `server_id` key `0057` added left every cascade from `mcp_servers` — and every lookup by server * — scanning this table. There is nothing about a missing index a functional test can observe, so * it is asked for by name. + * + * The keys are read from `pg_constraint` rather than listed, so a key added later is held to the + * same rule without anybody remembering this test — and the list is pinned too, so dropping or + * adding one is a visible edit. `user_id` lost its key (and its index) in `0062`; see the + * user-is-actor cases below. */ test("every foreign key on the table has an index leading with its column", async () => { + const keys = await database.execute(sql` + SELECT a.attname AS column + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'pending_authorizations'::regclass AND c.contype = 'f' + ORDER BY a.attname + `); + const columns = keys.map((row) => String(row.column)); const rows = await database.execute(sql` SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'pending_authorizations' @@ -302,10 +331,69 @@ describe("pending_authorizations keys the people it names", () => { definitions.some((definition) => definition.includes(`USING btree (${column}`), ); - expect({ - actor_id: led("actor_id"), - server_id: led("server_id"), - user_id: led("user_id"), - }).toEqual({ actor_id: true, server_id: true, user_id: true }); + expect( + Object.fromEntries(columns.map((column) => [column, led(column)])), + ).toEqual({ actor_id: true, server_id: true }); + }); +}); + +/** + * Whose deletion may take an attempt away: the person who STARTED it, and nobody else. + * + * The row is keyed by `actor_id` — one attempt per actor, by primary key — so it is the actor's. + * `user_id` is whose account the attempt is for, and for a `person` attempt that is always the + * actor too: `accountRefFor` answers a Personal app with `{ userId: actorId }`, and the only writer + * (`claimAuthorizationAttempt`) copies that ref. Before `0062` nothing in the DATABASE said so, and + * `user_id` carried a cascading key of its own — so a row naming two people died with EITHER of + * them, and one person's deletion destroyed an attempt somebody else had started. + * + * The first case is the observation itself, asked without a branch: try to write the two-person + * row, then — whatever the database did with it — give the actor their own attempt if the slot is + * still free (`ON CONFLICT DO NOTHING`), delete the OTHER person, and count what the actor has + * left. Against a schema that admits the two-person row, the actor's attempt is gone. + */ +describe("an attempt belongs to the person who started it", () => { + test("deleting one person never takes an attempt a different person started", async () => { + const actorId = `starter-${randomUUID().slice(0, 8)}`; + const otherId = `other-${randomUUID().slice(0, 8)}`; + await seedPeople(database, [actorId, otherId]); + seeded.add(actorId); + seeded.add(otherId); + + await refusedBy(() => + database + .insert(pendingAuthorizations) + // biome-ignore lint/suspicious/noExplicitAny: the two-person row is exactly what is being asked about. + .values(attempt({ actorId, userId: otherId }) as any), + ); + await database + .insert(pendingAuthorizations) + // biome-ignore lint/suspicious/noExplicitAny: a legal attempt, written past the declared unions like every row here. + .values(attempt({ actorId, userId: actorId }) as any) + .onConflictDoNothing(); + + await database.execute(sql`delete from users where id = ${otherId}`); + seeded.delete(otherId); + + const left = await database.execute( + sql`select count(*)::int as n from pending_authorizations where actor_id = ${actorId}`, + ); + expect(left[0]?.n).toBe(1); + }); + + test("a person attempt for somebody other than its actor is refused", async () => { + const actorId = `starter-${randomUUID().slice(0, 8)}`; + const otherId = `other-${randomUUID().slice(0, 8)}`; + await seedPeople(database, [actorId, otherId]); + seeded.add(actorId); + seeded.add(otherId); + expect( + await refusedBy(() => + database + .insert(pendingAuthorizations) + // biome-ignore lint/suspicious/noExplicitAny: the two-person row is exactly what is being asked about. + .values(attempt({ actorId, userId: otherId }) as any), + ), + ).toBe("pending_authorizations_user_is_actor_check"); }); }); diff --git a/server/tests/arcade-attempt-schema.test.ts b/server/tests/arcade-attempt-schema.test.ts index e59edfe78..5c8c05879 100644 --- a/server/tests/arcade-attempt-schema.test.ts +++ b/server/tests/arcade-attempt-schema.test.ts @@ -12,8 +12,8 @@ const suite = randomUUID().slice(0, 8); const serverId = `arcade-attempt-${suite}`; /* - * The three actors the cases below claim under, seeded as real people because `actor_id` and - * `user_id` now carry the key to `users` that they always claimed in the docblock. Named up here so + * The three actors the cases below claim under, seeded as real people because `actor_id` now carries + * the key to `users` it always claimed in the docblock (and `user_id` is held equal to it). Named up here so * `afterAll` can delete them, and so the cascade takes the attempts with them. */ const actors = [`live-${suite}`, `expired-${suite}`, `done-${suite}`]; diff --git a/server/tests/arcade-attempt-store.integration.test.ts b/server/tests/arcade-attempt-store.integration.test.ts index 16ac71860..4874aa834 100644 --- a/server/tests/arcade-attempt-store.integration.test.ts +++ b/server/tests/arcade-attempt-store.integration.test.ts @@ -82,7 +82,7 @@ const age = (actorId: string, interval: string) => /** * An actor id for this suite, AND THE PERSON BEHIND IT. * - * `pending_authorizations.actor_id` and `.user_id` both key to `users` and both cascade, so an + * `pending_authorizations.actor_id` keys to `users` and cascades (`.user_id` must equal it), so an * attempt cannot be written for somebody who does not exist. Seeding here rather than in each case * keeps the cases about the store: a case that invented its own id would be refused by the foreign * key, and the refusal would read as the store failing to claim. diff --git a/server/tests/arcade-authorize-cancel.test.ts b/server/tests/arcade-authorize-cancel.test.ts index 0486bd9d6..898a6276f 100644 --- a/server/tests/arcade-authorize-cancel.test.ts +++ b/server/tests/arcade-authorize-cancel.test.ts @@ -131,8 +131,8 @@ function appAs( /** * An actor id for this suite, AND THE `users` ROW BEHIND IT. * - * `pending_authorizations.actor_id` and `.user_id` key to `users` and cascade, so the attempt these - * cases cancel cannot be minted for somebody who does not exist. + * `pending_authorizations.actor_id` keys to `users` and cascades (`.user_id` must equal it), so the + * attempt these cases cancel cannot be minted for somebody who does not exist. */ const actors: string[] = []; const person = async (name: string) => { diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index 1ec318023..6e4fd62a1 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -123,8 +123,8 @@ function appAs( /** * An actor id for this suite, AND THE `users` ROW BEHIND IT. * - * `pending_authorizations.actor_id` and `.user_id` key to `users` and cascade, so the attempt a - * mint writes cannot name somebody who does not exist. + * `pending_authorizations.actor_id` keys to `users` and cascades (`.user_id` must equal it), so the + * attempt a mint writes cannot name somebody who does not exist. */ const actors: string[] = []; const person = async (name: string) => { diff --git a/server/tests/arcade-completion-transaction.integration.test.ts b/server/tests/arcade-completion-transaction.integration.test.ts index 1f69eae34..0cef5f8eb 100644 --- a/server/tests/arcade-completion-transaction.integration.test.ts +++ b/server/tests/arcade-completion-transaction.integration.test.ts @@ -123,8 +123,8 @@ const account = (actor: string): AccountRef => ({ /** * An actor id for this suite, AND THE PERSON BEHIND IT. * - * `pending_authorizations.actor_id` and `.user_id` key to `users` and cascade, so an attempt cannot - * exist for somebody who does not. Seeded here so each case stays about the completion transaction + * `pending_authorizations.actor_id` keys to `users` and cascades (`.user_id` must equal it), so an + * attempt cannot exist for somebody who does not. Seeded here so each case stays about the completion transaction * rather than about the foreign key. */ const people: string[] = []; diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index 4a0d4d972..03a5adb9b 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -588,7 +588,7 @@ async function consent(serverRowId: string, actionName: string) { beforeAll(async () => { // The administrator walking this journey is a real `users` row, because the attempt rows minted - // along the way key to one on both `actor_id` and `user_id` and cascade from it. + // along the way key to one on `actor_id` (with `user_id` held equal to it) and cascade from it. await seedPeople(database, [ADMIN]); installVendor(); const client = createArcadeClient("never-dialled"); diff --git a/server/tests/pending-authorization-audit-ledger.test.ts b/server/tests/pending-authorization-audit-ledger.test.ts index b38851394..472eadf69 100644 --- a/server/tests/pending-authorization-audit-ledger.test.ts +++ b/server/tests/pending-authorization-audit-ledger.test.ts @@ -289,11 +289,11 @@ const CASCADES: Record = { debt: "B5e", note: "`retireConnectionsFor` files rows for the connections it retires. An attempt that dies with its actor, possibly minted and live at the vendor, is not among them.", }, - pending_authorizations_user_id_users_id_fk: { - name: "cascade delete when the attempt's person (user_id) is deleted", - debt: "B5e", - note: "The same as the actor key, and a separate key, because nothing makes `user_id` equal to `actor_id`.", - }, + // `pending_authorizations_user_id_users_id_fk` was a third row here (debt B5e) until `0062` + // dropped it: with nothing making `user_id` equal `actor_id`, it let one person's deletion take an + // attempt a DIFFERENT person had started. `pending_authorizations_user_is_actor_check` now holds + // the equality and the actor key above is the only cascade from `users`. Removed with the key, + // and DEBT_CEILING lowered with it — not relabelled. }; /** @@ -318,7 +318,7 @@ const NON_WRITES: Record< * go DOWN. A fix that converts a row lowers it in the same diff. A new debt row has to raise it, * and that edit is the record that somebody decided to add one. */ -const DEBT_CEILING = 13; +const DEBT_CEILING = 12; /* * ─── THE SCAN ──────────────────────────────────────────────────────────────────────────────── From 962137ad9567b00bf7fb5053015e5886b93bf450 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 18:30:03 +0200 Subject: [PATCH 223/232] fix(arcade): do not hand out a consent url the mint could not bind (B5d) POST /servers/:id/authorize ignored recordAuthorizationMint's boolean. A cancel landing while the vendor was minting discarded the attempt, the mint's UPDATE then matched nothing, and the route still answered 200 with the bearer consent url: a vendor flow with no attempt row behind it, whose return leg could only be refused as no-attempt after the person granted. The route now answers 409 with a sentence saying the authorization was cancelled before the vendor finished starting it and nothing was connected. No url, no ar_ id in the body, nothing logged; the cancel has already filed mcp.authorization_cancelled. Call sites of recordAuthorizationMint (signature unchanged): - server/src/plugins/route-groups/connections.ts authorize POST (changed: now acts on false) - test helpers in arcade-completion-transaction / arcade-attempt-store integration tests (unaffected) Ledger: the declined-mint branch is a write-free transition, listed in NON_WRITES as waived (the attempt's ending is the cancel, already audited). Debt ceiling unchanged at 13; the mint debt note no longer points at B5d. Co-Authored-By: Claude Opus 5.5 --- .../src/plugins/plugin-store/connections.ts | 4 +- .../src/plugins/route-groups/connections.ts | 34 +++++++- server/tests/arcade-authorize-route.test.ts | 80 +++++++++++++++++++ ...pending-authorization-audit-ledger.test.ts | 10 ++- 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/server/src/plugins/plugin-store/connections.ts b/server/src/plugins/plugin-store/connections.ts index cadf27f87..5fdfbed27 100644 --- a/server/src/plugins/plugin-store/connections.ts +++ b/server/src/plugins/plugin-store/connections.ts @@ -1080,8 +1080,8 @@ export function connectionMethods(context: StoreContext) { * * The first write is the only one, and it can be NULL: a vendor that answered with no id * leaves the attempt null, and it expires. Answers whether it wrote. False means the attempt - * was discarded, replaced, already minted or already settled. The route does not act on the - * answer. + * was discarded, replaced, already minted or already settled. The authorize route acts on it: + * on false it does not hand out the consent url, because nothing here records that flow. */ async recordAuthorizationMint(input: { actorId: string; diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts index 9812afc75..4ff2d05c9 100644 --- a/server/src/plugins/route-groups/connections.ts +++ b/server/src/plugins/route-groups/connections.ts @@ -1526,8 +1526,9 @@ export function mountConnectionRoutes({ ); } // A null id is recorded as NULL: an attempt holding none can never complete and simply expires. + let bound: boolean; try { - await store.recordAuthorizationMint({ + bound = await store.recordAuthorizationMint({ actorId: context.var.actor.id, createdAt: claim.attempt.createdAt, authId: minted.authorizationId, @@ -1594,6 +1595,37 @@ export function mountConnectionRoutes({ 500, ); } + if (!bound) { + /* + * THE MINT WROTE NOTHING, SO THE CONSENT URL IS NOT HANDED OUT. + * + * `recordAuthorizationMint` answers false when no unminted, unsettled attempt stands under + * `(actor_id, created_at)`. On this route that is a cancel that landed while the vendor was + * minting: `DELETE /servers/:id/authorize` discarded the attempt (and filed + * `mcp.authorization_cancelled` for it), and only then did the vendor answer. The url it + * answered is a bearer capability for a flow with NO ATTEMPT BEHIND IT. Handing it out sent + * the person to grant access at the vendor against a record that does not exist, and the + * return leg then refused that consent as `no-attempt`, after the grant had been made. The + * person asked for nothing to be started, so nothing is. + * + * 409, THE STATUS THE CANCEL ROUTE ANSWERS WHEN IT AND A COMPLETION CROSS, because this is + * that kind of race seen from the other request: two acts on one attempt, and the one that + * lost is told so in an error a client cannot mistake for a url to follow. Nothing is + * discarded here: false means no attempt of this request's is left to discard, and a newer + * or settled row by the same actor is not this request's to touch. + * + * NOTHING IS LOGGED. The flow at the vendor is unreachable: its url was given to nobody and + * is written nowhere, so it expires there. The person's cancel is already on the trail. + * Neither the url nor the `ar_...` reaches this body. + */ + return context.json( + { + error: + "That authorization was cancelled before the app's vendor finished starting it, so you were not sent on and nothing was connected. Press Authorize to start again.", + }, + 409, + ); + } return context.json({ authorizationUrl: minted.redirectUrl }); }); diff --git a/server/tests/arcade-authorize-route.test.ts b/server/tests/arcade-authorize-route.test.ts index 1ec318023..25497e33e 100644 --- a/server/tests/arcade-authorize-route.test.ts +++ b/server/tests/arcade-authorize-route.test.ts @@ -611,4 +611,84 @@ describe("POST /servers/:id/authorize", () => { .where(eq(pendingAuthorizations.actorId, SHARED_VENDOR_ID)); expect(stray).toEqual([]); }); + + describe("a mint that the store declines to record", () => { + const cancel = (app: Hono, serverId: string) => + app.request(`/api/plugins/servers/${serverId}/authorize`, { + method: "DELETE", + }); + + /* + * B5d. The mint is held open at the vendor, the person cancels, and only then does the vendor + * answer. `recordAuthorizationMint` finds no attempt under `(actor_id, created_at)` and writes + * nothing. The consent url the vendor answered is a bearer capability for a flow that this + * deployment no longer has any record of, so it must not be handed out. + */ + test("a cancel that lands while the mint is in flight: no consent url, no attempt, a 409 that says why", async () => { + const actor = await person("cancel-mid-mint"); + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered: () => void = () => {}; + const minting = new Promise((resolve) => { + entered = resolve; + }); + beforeMint = () => { + entered(); + return held; + }; + const app = appAs("user", actor); + const started = authorize(app, personalApp); + await minting; + // The attempt is standing with no auth_id while the vendor has not answered yet. + expect(await attemptsFor(actor)).toMatchObject([{ authId: null }]); + const cancelled = await cancel(app, personalApp); + expect(cancelled.status).toBe(200); + expect(await cancelled.json()).toEqual({ cancelled: true }); + release(); + const response = await started; + const text = await response.text(); + // The vendor did mint, and nothing stands behind it here. + expect(mints).toHaveLength(1); + expect(await attemptsFor(actor)).toEqual([]); + expect(text).not.toContain("bearer-capability"); + expect(text).not.toContain("ar_minted_1"); + expect(response.status).toBe(409); + const body = JSON.parse(text) as { + error?: string; + authorizationUrl?: string; + }; + expect(body.authorizationUrl).toBeUndefined(); + expect(body.error).toContain("cancelled"); + // Nothing holds the actor: starting over mints again and binds the new attempt. + beforeMint = null; + mints = []; + nextAuthId = "ar_after_cancel"; + const again = await authorize(app, personalApp); + expect(again.status).toBe(200); + expect(await again.json()).toEqual({ authorizationUrl: SECRET_URL }); + expect(await attemptsFor(actor)).toMatchObject([ + { authId: "ar_after_cancel", completedAt: null }, + ]); + }); + + test("whatever the reason the write declined, the consent url is not handed out", async () => { + const actor = await person("mint-declined"); + const response = await authorize( + appAs("user", actor, "arcade", { + recordAuthorizationMint: async () => false, + }), + personalApp, + ); + const text = await response.text(); + expect(mints).toHaveLength(1); + expect(text).not.toContain("bearer-capability"); + expect(text).not.toContain("ar_minted_1"); + expect(response.status).toBe(409); + expect( + (JSON.parse(text) as { authorizationUrl?: string }).authorizationUrl, + ).toBeUndefined(); + }); + }); }); diff --git a/server/tests/pending-authorization-audit-ledger.test.ts b/server/tests/pending-authorization-audit-ledger.test.ts index b38851394..564a6fcc3 100644 --- a/server/tests/pending-authorization-audit-ledger.test.ts +++ b/server/tests/pending-authorization-audit-ledger.test.ts @@ -208,7 +208,7 @@ const CALL_SITES: Record< { name: "mint recorded (attempt bound to the vendor's authorization)", debt: "B5e", - note: "The binding the return leg is checked against is written without a trail row. B5d (the ignored `false`) sits on the same call.", + note: "The binding the return leg is checked against is written without a trail row. The `false` this call can answer is its own hand-listed transition in NON_WRITES.", }, ], }, @@ -311,6 +311,14 @@ const NON_WRITES: Record< debt: "B2c", note: "The person asked to retract consent and the consent stood. The 409 tells them, and the trail records nothing about the request. The discard deleted nothing, so no write site exists for this guard to find.", }, + "mint that found its attempt already cancelled": { + file: "server/src/plugins/route-groups/connections.ts", + anchor: + "That authorization was cancelled before the app's vendor finished starting it", + name: "mint declined because the attempt was cancelled while the vendor was minting (B5d)", + waived: + "This branch ends no attempt: the attempt it would describe was already ended by the person's cancel, and that cancel filed `mcp.authorization_cancelled` for it. A second row would record the same withdrawal twice. What the branch adds is a refusal to hand out the consent url, and that url, like the `ar_...`, must never reach the trail.", + }, }; /** From 1a9728a9b9ede3da160f74fbaafb45943efc1b2c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 18:30:13 +0200 Subject: [PATCH 224/232] fix(arcade): one absent/unreadable/read reading for every vendor authorization field (K2) Class K2: authorization reads that failed OPEN. C2's tri-state (authorizationRequirementOf) never reached the sibling readers, each of which picked the permissive default for what it could not read. The class fix: three shared readers in server/src/plugins/arcade.ts (the transport, which the adapter already imports), with one rule stated once: absent (missing/undefined/null) -> the field's documented default unreadable (wrong shape, BLANK string, a list holding a non-scope) -> fails closed at every caller read -> status folded+trimmed / scope list - authorizationRequirementOf, shapeOf: MOVED from arcade-adapter.ts unchanged (shapeOf now names a blank string itself). - requiredScopesOf: NEW, the oauth2/scopes level of the same reading. - vendorStatusOf: NEW, every vendor status field. Readers now on it (call sites): - grantStateOf (adapter): token_status only (status is the PROVIDER's standing); absent/unreadable token_status -> unknown; unreadable scopes -> unknown; folded. [A7c, C2c] - scopesOf (adapter, listApps): unreadable scopes -> BrokerRefusalError; requiresAuthorization now asked first. [C2c] - scopesOf (arcade.ts, listTools): now takes the tool; unreadable authorization/oauth2/scopes -> listing refuses, never []. [C2c] - connectionIsLive: unreadable/blank -> not live; null -> absent -> live. [A7b] - authorize (mint status): unreadable/blank -> BrokerRefusalError, url never handed on. [L2-b] - authorizations.status: folded; absent/unreadable -> "unknown". - authProvidersNamedBy: uses shapeOf for the blank case (same output). Contradictions settled: null is ABSENCE on every field (C2's reading; the A7b null row is withdrawn); a blank string is UNREADABLE on every decision field (C2b's reading; "absent or empty reads as live" is withdrawn). Tests: - arcade-vendor-read-contract: KNOWN_FAIL_OPEN 43 -> 0; connection "" row now not-live, null row now live; +8 mint-status rows, +9 partial-scope rows. - arcade-authorize-status-engine: D6 rows (not_started, padded Pending, padded NOT_STARTED, "already" sentence, explicit null) and L2-b rows. - arcade-adapter-connections: blank connection_status now not live. - arcade-journey: fixture carries token_status per identity instead of flipping the provider's status to "active". - mutation-register: D6 survivesUntil removed (both now killed); fold entries and arcade-unreadable-authorization-read-as-none re-pathed to arcade.ts; +3 entries (unreadable scopes, unreadable status, missing token_status). - typecheck-baseline: two pre-existing entries re-keyed (line shift; union print order), no new errors. Co-Authored-By: Claude Opus 5.5 --- scripts/mutation-register.ts | 55 ++++- server/src/plugins/arcade-adapter.ts | 219 +++++++++-------- server/src/plugins/arcade.ts | 220 +++++++++++++++++- .../tests/arcade-adapter-connections.test.ts | 15 +- .../arcade-authorize-status-engine.test.ts | 83 ++++++- .../tests/arcade-journey.integration.test.ts | 31 ++- .../tests/arcade-vendor-read-contract.test.ts | 130 +++++------ server/tests/typecheck-baseline.txt | 4 +- 8 files changed, 536 insertions(+), 221 deletions(-) diff --git a/scripts/mutation-register.ts b/scripts/mutation-register.ts index f30a16d67..15283e31d 100644 --- a/scripts/mutation-register.ts +++ b/scripts/mutation-register.ts @@ -118,9 +118,12 @@ export const REGISTER: MutationEntry[] = [ { id: "arcade-connection-status-unfolded", kind: "casing", - path: "server/src/plugins/arcade-adapter.ts", - find: "const status = textOf(row.connection_status)?.trim().toLowerCase();", - replace: "const status = textOf(row.connection_status);", + // K2: every vendor status is folded in ONE place, `vendorStatusOf` in the transport, so the + // per-site expressions this entry and the next once quoted are gone. Both re-quote the shared + // fold; each still names the suites that must see it for ITS reader. + path: "server/src/plugins/arcade.ts", + find: 'return { kind: "text", text: value.trim().toLowerCase() };', + replace: 'return { kind: "text", text: value };', tests: [ "server/tests/arcade-adapter-connections.test.ts", "server/tests/arcade-adapter-status.test.ts", @@ -130,12 +133,11 @@ export const REGISTER: MutationEntry[] = [ { id: "arcade-mint-status-unfolded", kind: "casing", - path: "server/src/plugins/arcade-adapter.ts", - find: "const status = textOf(answer.status)?.trim().toLowerCase();", - replace: "const status = textOf(answer.status);", + path: "server/src/plugins/arcade.ts", + find: 'return { kind: "text", text: value.trim().toLowerCase() };', + replace: 'return { kind: "text", text: value };', tests: ["server/tests/arcade-authorize-status-engine.test.ts"], - why: "D6: deleting the fold on the mint's own status was reported to leave arcade-authorize-status-engine green, because :118 asserts only BrokerRefusalError, which the allowlist yields anyway.", - survivesUntil: "D6", + why: "D6: deleting the fold on the mint's own status was reported to leave arcade-authorize-status-engine green, because :118 asserts only BrokerRefusalError, which the allowlist yields anyway. K2 added padded/re-cased allowlist rows that are handed on only when folded.", }, { id: "arcade-mint-not-started-dropped", @@ -144,13 +146,13 @@ export const REGISTER: MutationEntry[] = [ find: 'const MINT_AWAITS_CONSENT_STATUSES = new Set(["not_started", "pending"]);', replace: 'const MINT_AWAITS_CONSENT_STATUSES = new Set(["pending"]);', tests: ["server/tests/arcade-authorize-status-engine.test.ts"], - why: "D6: not_started, half the allowlist, is untested — a mint that has not begun would be refused as if it were finished.", - survivesUntil: "D6", + why: "D6: not_started, half the allowlist, is untested — a mint that has not begun would be refused as if it were finished. K2 added the not_started rows.", }, { id: "arcade-unreadable-authorization-read-as-none", kind: "absent-vs-unreadable", - path: "server/src/plugins/arcade-adapter.ts", + // K2 moved `authorizationRequirementOf` into the transport, which both readers import. + path: "server/src/plugins/arcade.ts", anchor: 'if (typeof authorization !== "object" || Array.isArray(authorization)) {', find: 'kind: "unreadable",', @@ -162,6 +164,37 @@ export const REGISTER: MutationEntry[] = [ ], why: "C2/C2b: an authorization block that cannot be read is not the vendor saying none — reading it as none silently drops the consent requirement.", }, + { + id: "arcade-unreadable-scopes-read-as-none", + kind: "absent-vs-unreadable", + path: "server/src/plugins/arcade.ts", + find: 'return { kind: "unreadable", field: "scopes", shape: shapeOf(scopes) };', + replace: 'return { kind: "read", scopes: [] };', + tests: ["server/tests/arcade-vendor-read-contract.test.ts"], + why: "K2 (C2c): an unreadable oauth2 scopes value read as [] is 'the connection is the grant' on the gate, the catalogue and the grants screen at once.", + }, + { + id: "arcade-missing-token-status-read-as-held", + kind: "absent-vs-unreadable", + path: "server/src/plugins/arcade-adapter.ts", + find: 'if (tokenStatus.kind !== "text") {', + replace: + 'if (tokenStatus.kind !== "text") return [{ action, state: "authorized", scopes, authorization: "held" }]; if (false) {', + tests: ["server/tests/arcade-vendor-read-contract.test.ts"], + why: "K2 (A7c): a token_status the listing did not give read as a held grant marked every action of a configured provider authorized for everyone.", + }, + { + id: "arcade-unreadable-status-read-as-absent", + kind: "absent-vs-unreadable", + path: "server/src/plugins/arcade.ts", + find: 'return { kind: "unreadable", shape: shapeOf(value) };', + replace: 'return { kind: "absent" };', + tests: [ + "server/tests/arcade-vendor-read-contract.test.ts", + "server/tests/arcade-authorize-status-engine.test.ts", + ], + why: "K2 (A7b, L2-b): a present-but-unreadable vendor status read as silence makes a connection live and hands a mint's bearer url on as a redirect.", + }, { id: "arcade-auth-providers-skip-unreadable", kind: "absent-vs-unreadable", diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index bce919b01..1b251a029 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -3,7 +3,11 @@ import { type ArcadeActions, type ArcadeFailure, ArcadeFailureError, + authorizationRequirementOf, classify, + requiredScopesOf, + shapeOf, + vendorStatusOf, } from "./arcade"; import { type BrokerApp, @@ -429,85 +433,22 @@ function isClassified(metadata: unknown): boolean { return behavior !== null && flagOf(behavior.destructive, false) === true; } -/** The oauth2 scopes one listed action asks for, by the path `grantStateOf` reads them on. */ -function scopesOf(item: unknown): string[] { - const authorization = record(record(item)?.requirements)?.authorization; - const scopes = record(authorization)?.oauth2; - const listed = record(scopes)?.scopes; - return Array.isArray(listed) - ? listed.filter((s): s is string => typeof s === "string") - : []; -} - /** - * WHAT ARRIVED WHERE A SHAPE BELONGED, named without quoting it. - * - * A listing row carries whatever the vendor chose to put on it, and a refusal from here is read off - * an admin page, so the SHAPE is the part that is both safe to say and the part that helps — - * `sent` in `composio-adapter.ts` says this at length and this is the same judgement, kept local - * because that file is the other vendor's door and neither imports the other. + * The oauth2 scopes one listed action asks for, for the catalogue's counts — read through + * {@link requiredScopesOf}, the one reading `grantStateOf` and the transport's listing share. + * + * AN UNREADABLE SCOPES BLOCK REFUSES, as {@link requiresAuthorization} refuses an unreadable + * authorization block, and for its reason: `[]` here files the action as unscoped — "the connection + * itself is the grant" — so a block this deployment could not read would be counted as the vendor + * saying no separate permission exists. The SHAPE (`authorizationRequirementOf`, `shapeOf`) lives in + * `./arcade` because the transport's own listing reads the same block for the scopes the gate checks. */ -function shapeOf(value: unknown): string { - if (Array.isArray(value)) return "a list"; - // `typeof null` IS `"object"`, so the bare template would write "a object" — the wrong grammar - // and, worse, the wrong fact: a hole is not a shape this deployment read and could not use, it - // is the absence of one. The callers that pass a present-but-unreadable field guard both of - // these before asking; `connectionsFor`'s unreadable ROW is the one that can carry a `null`. - if (value === null || value === undefined) return "nothing"; - return `a ${typeof value}`; -} - -/** - * What one listed action's `requirements.authorization` SAYS, read once for every caller that asks. - * - * THE ONE READING OF THIS FIELD'S ABSENCE. Two readers once reached opposite answers on the same - * row: {@link requiresAuthorization} read a missing block as "needs nothing" and filed the app as - * `no-auth`, while {@link grantStateOf} read the same silence as "could not be read" and drew every - * action of that app as a broker outage on the grants screen, forever. The vendor's own listing - * settles which is right — `Apollo` publishes NO authorization block and a deployment secret - * instead, verbatim in `fixtures/arcade-catalogue.json` — so `null` and an absent field are - * `none`, here and therefore everywhere. - * - * A PRESENT FIELD THIS DEPLOYMENT CANNOT READ IS NEITHER ANSWER. It is `unreadable`, with WHICH - * field and the shape that arrived there, and each caller decides what an unreadable row costs it: - * the catalogue refuses, the grants screen says `unknown`. - */ -type AuthorizationRequirement = - | { kind: "none" } - | { - kind: "unreadable"; - field: "requirements" | "authorization"; - shape: string; - } - | { kind: "required"; authorization: Record }; - -function authorizationRequirementOf(item: unknown): AuthorizationRequirement { - const requirements = record(item)?.requirements; - if (requirements === undefined || requirements === null) { - return { kind: "none" }; - } - if (typeof requirements !== "object" || Array.isArray(requirements)) { - return { - kind: "unreadable", - field: "requirements", - shape: shapeOf(requirements), - }; - } - const authorization = (requirements as Record).authorization; - if (authorization === undefined || authorization === null) { - return { kind: "none" }; - } - if (typeof authorization !== "object" || Array.isArray(authorization)) { - return { - kind: "unreadable", - field: "authorization", - shape: shapeOf(authorization), - }; - } - return { - kind: "required", - authorization: authorization as Record, - }; +function scopesOf(item: unknown, toolkit: string): string[] { + const read = requiredScopesOf(item); + if (read.kind === "read") return read.scopes; + throw new BrokerRefusalError( + `Arcade sent ${read.shape} where the ${read.field === "requirements" || read.field === "authorization" ? read.field : `oauth2 ${read.field}`} of one of ${toolkit}'s actions belongs, and that is what says which permissions connecting ${toolkit} asks for. A shape this deployment cannot read is not "no separate permission", so the catalogue stops here rather than count it as one. That is a change in what Arcade answers rather than a setting an operator can correct, so upgrading this deployment's @arcadeai/arcadejs is what fixes it.`, + ); } /** @@ -636,10 +577,11 @@ async function listApps(client: Arcade): Promise { const name = textOf(toolkit?.name); if (!name) continue; const classified = isClassified(record(item)?.metadata); - const scopes = scopesOf(item); // READ OFF THIS ACTION, never written down for the catalogue; see {@link requiresAuthorization}. - // Asked AFTER the name, so a refusal can say which app it is about. + // Asked AFTER the name, so a refusal can say which app it is about, and BEFORE the scopes, so an + // unreadable authorization block is refused in that function's words rather than the scopes'. const authorizes = requiresAuthorization(item, name); + const scopes = scopesOf(item, name); const standing = counts.get(name) ?? ({ @@ -939,6 +881,19 @@ const LIVE_CONNECTION_STATUS = "active"; */ const MINT_AWAITS_CONSENT_STATUSES = new Set(["not_started", "pending"]); +/** + * The listing's `token_status` spellings under which this account holds no usable token and a + * consent is what would give it one. `completed` is the held grant; anything else is `unknown`. + * Kept apart from {@link MINT_AWAITS_CONSENT_STATUSES} for the reason that set's docblock gives, + * even where the spellings overlap: `failed` belongs here (a failed token is one to consent again + * for) and not there (a failed MINT is not a consent screen to send somebody to). + */ +const TOKEN_AWAITS_CONSENT_STATUSES = new Set([ + "not_started", + "pending", + "failed", +]); + /** * Whether a connection row is one somebody can actually USE, read off the row's OWN `connection_status`. * @@ -949,14 +904,22 @@ const MINT_AWAITS_CONSENT_STATUSES = new Set(["not_started", "pending"]); * reporting as expired, revoked at the provider, pending or failed. Nothing then goes wrong until a * tool call fails, far from the screen that said Connected, with no way back but guessing. * - * SILENCE IS NOT A NO. An ABSENT or empty status reads as live, deliberately: the field is OPTIONAL - * on the wire (`connection_status?: string`), so a pessimistic reading would answer "not connected" - * for every real connection against any engine that omits it — `isConnected` false, `accountName` - * null and `revoke` false WITHOUT SPENDING A DELETE, which is precisely the live defect this seam + * SILENCE IS NOT A NO. An ABSENT status — the key missing, `undefined` or `null` — reads as live, + * deliberately: the field is OPTIONAL on the wire (`connection_status?: string`), so a pessimistic + * reading would answer "not connected" for every real connection against any engine that omits it + * — `isConnected` false and `accountName` null — which is precisely the live defect this seam * already suffered once when it compared a provider TYPE to a provider INSTANCE. The rule is * therefore: the vendor said something other than live → not live; the vendor said nothing → the * existence of the row is all the evidence there is, and it stands. * + * BUT A STATUS THAT IS PRESENT AND CANNOT BE READ IS NOT SILENCE. `{}`, `false`, `0`, a list, or a + * BLANK string is the vendor saying SOMETHING about this connection in a shape this deployment does + * not read — and "live" is the one answer that would act on it. It reads as not live, by + * {@link vendorStatusOf}'s rule: a blank string on a field whose value is a decision is unreadable, + * the judgement `authProvidersNamedBy` already makes of a blank `provider_id`. (This file once said + * "absent or empty reads as live"; the empty half is withdrawn, so that one rule — absent is + * `undefined`/`null`, blank is unreadable — holds on every vendor authorization field.) + * * FOLDED AND TRIMMED, Amendment 16's client-side fold, for the reason every other comparison in * this file is: a case the vendor changes must not change an answer about somebody's account. * @@ -966,9 +929,9 @@ const MINT_AWAITS_CONSENT_STATUSES = new Set(["not_started", "pending"]); * POSITIVE reads — `isConnected`, `accountName` — ask this question. */ function connectionIsLive(row: ConnectionRow): boolean { - const status = textOf(row.connection_status)?.trim().toLowerCase(); - if (status === undefined || status === "") return true; - return status === LIVE_CONNECTION_STATUS; + const status = vendorStatusOf(row.connection_status); + if (status.kind === "absent") return true; + return status.kind === "text" && status.text === LIVE_CONNECTION_STATUS; } /** @@ -1092,22 +1055,48 @@ function grantStateOf(item: unknown): GrantState[] { }, ]; } - const { authorization } = read; - const oauth2 = record(authorization.oauth2)?.scopes; - const scopes = Array.isArray(oauth2) - ? oauth2.filter((s): s is string => typeof s === "string") - : []; - const tokenStatus = textOf(authorization.token_status); - const status = textOf(authorization.status); - if (tokenStatus === "completed" || (!tokenStatus && status === "active")) { + // THE SCOPES BY THE ONE READING, see {@link requiredScopesOf}: an unreadable `oauth2` or + // `scopes` is `unknown`, never `[]` — `authorized []` is "the connection is the whole grant", + // which is an answer about this account the listing did not give. + const scopesRead = requiredScopesOf(item); + if (scopesRead.kind === "unreadable") { + return [ + { + action, + state: "unknown", + reason: `Arcade listed the permissions this action needs as ${scopesRead.shape}, so what this account holds could not be read.`, + }, + ]; + } + const { scopes } = scopesRead; + /* + * `token_status` IS THE WHOLE OF WHAT THIS ACCOUNT HOLDS, AND `status` IS NOT READ HERE AT ALL. + * The two sit side by side and answer different questions: `status` (`active`/`inactive`, with a + * `status_reason` such as "Provider not found" in `fixtures/arcade-catalogue.json`) is the AUTH + * PROVIDER's standing at this deployment, and is the same for everybody; `token_status` is this + * account's token. Reading `status: "active"` as a grant when `token_status` was missing marked + * EVERY action of a configured provider as held by everyone the listing was asked about (A7c), and + * reading `inactive` as "go and consent" sends somebody to a consent screen for a provider that + * is not there. So an absent or unreadable `token_status` is `unknown`, with the reason, and the + * text is FOLDED AND TRIMMED by {@link vendorStatusOf} as every other vendor status is. + */ + const tokenStatus = vendorStatusOf(read.authorization.token_status); + if (tokenStatus.kind !== "text") { + return [ + { + action, + state: "unknown", + reason: + tokenStatus.kind === "absent" + ? "Arcade's listing did not say whether this account holds a token for this action." + : `Arcade's listing gave this account's token status for this action as ${tokenStatus.shape}, so whether it is held could not be read.`, + }, + ]; + } + if (tokenStatus.text === "completed") { return [{ action, state: "authorized", scopes, authorization: "held" }]; } - if ( - tokenStatus === "not_started" || - tokenStatus === "pending" || - tokenStatus === "failed" || - status === "inactive" - ) { + if (TOKEN_AWAITS_CONSENT_STATUSES.has(tokenStatus.text)) { return [{ action, state: "needs-consent", scopes }]; } return [ @@ -1115,7 +1104,7 @@ function grantStateOf(item: unknown): GrantState[] { action, state: "unknown", reason: - "Arcade's listing gave this action's authorization neither a status nor a token status.", + "Arcade's listing gave this account's token status for this action in a spelling this deployment does not read.", }, ]; } @@ -1324,10 +1313,7 @@ async function authProvidersNamedBy( unreadable ??= { action, field: "provider_id", - shape: - typeof providerId === "string" - ? "a blank string" - : shapeOf(providerId), + shape: shapeOf(providerId), }; } return { named, unreadable }; @@ -1477,9 +1463,13 @@ export function createArcadeClient(apiKey: string): { // spelling goes on sending a browser to it, which is the defect itself. // // SILENCE IS NOT A NO, as it is not in {@link connectionIsLive}: `status` is OPTIONAL on the - // wire, so an absent or blank field leaves the url and the id as the whole of the evidence - // and they stand. FOLDED AND TRIMMED for this file's standing reason: a case the vendor - // changes must not change what somebody is told about their own account. + // wire, so an ABSENT field (missing, `undefined`, `null`) leaves the url and the id as the + // whole of the evidence and they stand. A status that is PRESENT AND UNREADABLE — `{}`, + // `false`, a number, a blank string — is not silence: the vendor said something about this + // authorization and this deployment cannot tell what, so the bearer url beside it is NOT + // handed on as a live redirect. {@link vendorStatusOf} is the one reading of both, and it + // FOLDS AND TRIMS the text for this file's standing reason: a case the vendor changes must not + // change what somebody is told about their own account. // // A `BrokerRefusalError` AND NOT A BARE `Error`, which is the difference between being told // and being locked out. The mint route keys its three outcomes on what was thrown: an @@ -1487,7 +1477,13 @@ export function createArcadeClient(apiKey: string): { // again at once, with this sentence shown; a bare `Error` normalizes to `null` and is read as // "the mint may be live", leaving the attempt standing for its fifteen-minute TTL. Nothing // IS live here — the vendor answered, and said this is not a consent to walk through. - const status = textOf(answer.status)?.trim().toLowerCase(); + const read = vendorStatusOf(answer.status); + if (read.kind === "unreadable") { + throw new BrokerRefusalError( + `Arcade answered this account's authorization for ${action} with ${read.shape} where its status belongs, so whether a consent screen is the next step could not be read and nobody was sent anywhere. Press Authorize again to start a fresh one.`, + ); + } + const status = read.kind === "text" ? read.text : undefined; if (status !== undefined && !MINT_AWAITS_CONSENT_STATUSES.has(status)) { throw new BrokerRefusalError( status === "completed" @@ -1595,8 +1591,11 @@ export function createArcadeClient(apiKey: string): { ), ); const scopes = answer.scopes; + // THE ONE STATUS READING ({@link vendorStatusOf}): folded and trimmed, and an absent or + // unreadable status is `"unknown"`, which the return leg refuses as not finished. + const settled = vendorStatusOf(answer.status); return { - status: textOf(answer.status) ?? "unknown", + status: settled.kind === "text" ? settled.text : "unknown", userId: textOf(answer.user_id) ?? null, scopes: Array.isArray(scopes) ? scopes.filter((s): s is string => typeof s === "string") diff --git a/server/src/plugins/arcade.ts b/server/src/plugins/arcade.ts index 3c97edb80..42b80e86c 100644 --- a/server/src/plugins/arcade.ts +++ b/server/src/plugins/arcade.ts @@ -131,7 +131,205 @@ function record(value: unknown): Record | null { } /** - * The OAuth scopes an action needs: `requirements.authorization.oauth2.scopes`. + * WHAT ARRIVED WHERE A SHAPE BELONGED, named without quoting it. + * + * A listing row carries whatever the vendor chose to put on it, and a refusal from here is read off + * an admin page, so the SHAPE is the part that is both safe to say and the part that helps — + * `sent` in `composio-adapter.ts` says this at length and this is the same judgement, kept local + * because that file is the other vendor's door and neither imports the other. + */ +export function shapeOf(value: unknown): string { + if (Array.isArray(value)) return "a list"; + // `typeof null` IS `"object"`, so the bare template would write "a object" — the wrong grammar + // and, worse, the wrong fact: a hole is not a shape this deployment read and could not use, it + // is the absence of one. The callers that pass a present-but-unreadable field guard both of + // these before asking; `connectionsFor`'s unreadable ROW is the one that can carry a `null`. + if (value === null || value === undefined) return "nothing"; + if (typeof value === "string" && value.trim() === "") return "a blank string"; + return `a ${typeof value}`; +} + +/* + * --------------------------------------------------------------------------------------------- + * THE ONE READING OF A VENDOR AUTHORIZATION FIELD — absent, unreadable, or read. + * + * Every Arcade field this deployment draws an AUTHORIZATION conclusion from is read through one of + * the three functions below, and all three apply the same rule, stated once here: + * + * ABSENT — the key missing, `undefined`, or `null` — is the vendor saying nothing, and answers the + * field's documented default. `null` IS ABSENCE ON EVERY FIELD, NOT ON SOME: the SDK declares + * these fields optional, Arcade sends `metadata: null` and `requirements: null` for "none" + * (`AirtableApi`, `Apollo` in `fixtures/arcade-catalogue.json`), and a serializer that writes an + * unset optional as `null` rather than dropping the key is saying what a missing key says. A rule + * under which `null` meant "absent" on one field and "a value I cannot read" on the next would + * make the same vendor answer mean opposite things one line apart. + * + * UNREADABLE — present, but not the field's shape — is NEITHER default. Each caller decides what + * an unreadable field costs it, and every caller fails CLOSED: the catalogue and the transport + * listing refuse, the grants screen says `unknown`, a connection is not live, a mint is refused. + * A BLANK STRING IS UNREADABLE on a field whose value is a decision. It is present (so it is not + * absence) and carries no value (so it is not text); `authProvidersNamedBy` already refuses a + * blank `provider_id` for exactly this reason. `textOf` in `arcade-adapter.ts` reads a blank + * LABEL as absence, which is right for a display name and is not the question asked here. + * + * READ — the value in this deployment's vocabulary: a status FOLDED AND TRIMMED (a case or a + * space the vendor changes must not change an answer about somebody's account), a scope list + * whose every element is a scope. + * + * WHY ONE READING. `authorizationRequirementOf` (C2) answered this tri-state for the authorization + * block, and every OTHER reader of a field inside or beside it picked its own default for what it + * could not read — the permissive one, each time: a missing `token_status` fell back to the + * PROVIDER's `status: "active"` and drew every action as held; an unreadable `oauth2`/`scopes` + * became `[]`, which the gate reads as "the connection itself is the grant"; a non-text + * `connection_status` read as live; a non-text mint `status` handed a bearer url on as a live + * redirect. One reading means a new reader cannot pick a sixth default by accident. + * --------------------------------------------------------------------------------------------- + */ + +/** + * What one listed action's `requirements.authorization` SAYS, read once for every caller that asks. + * + * THE ONE READING OF THIS FIELD'S ABSENCE. Two readers once reached opposite answers on the same + * row: `requiresAuthorization` read a missing block as "needs nothing" and filed the app as + * `no-auth`, while `grantStateOf` read the same silence as "could not be read" and drew every + * action of that app as a broker outage on the grants screen, forever. The vendor's own listing + * settles which is right — `Apollo` publishes NO authorization block and a deployment secret + * instead, verbatim in `fixtures/arcade-catalogue.json` — so `null` and an absent field are + * `none`, here and therefore everywhere. + * + * A PRESENT FIELD THIS DEPLOYMENT CANNOT READ IS NEITHER ANSWER. It is `unreadable`, with WHICH + * field and the shape that arrived there, and each caller decides what an unreadable row costs it: + * the catalogue refuses, the grants screen says `unknown`. + * + * HERE, IN THE TRANSPORT, AND NOT IN THE ADAPTER, because the transport's own listing reads the same + * block for the scopes the gate checks, and the adapter imports this module, never the reverse. + */ +export type AuthorizationRequirement = + | { kind: "none" } + | { + kind: "unreadable"; + field: "requirements" | "authorization"; + shape: string; + } + | { kind: "required"; authorization: Record }; + +export function authorizationRequirementOf( + item: unknown, +): AuthorizationRequirement { + const requirements = record(item)?.requirements; + if (requirements === undefined || requirements === null) { + return { kind: "none" }; + } + if (typeof requirements !== "object" || Array.isArray(requirements)) { + return { + kind: "unreadable", + field: "requirements", + shape: shapeOf(requirements), + }; + } + const authorization = (requirements as Record).authorization; + if (authorization === undefined || authorization === null) { + return { kind: "none" }; + } + if (typeof authorization !== "object" || Array.isArray(authorization)) { + return { + kind: "unreadable", + field: "authorization", + shape: shapeOf(authorization), + }; + } + return { + kind: "required", + authorization: authorization as Record, + }; +} + +/** + * The OAuth scopes one listed action asks for — `requirements.authorization.oauth2.scopes` — read + * by {@link authorizationRequirementOf}'s rule, one level further in. + * + * `[]` IS A CLAIM, NOT A SHRUG. An action whose scopes are `[]` is one whose connection IS the + * grant (Github's 43 actions publish exactly that), and the gate, the catalogue's counts and the + * grants screen all read it so. So `[]` is answered only when the vendor SAID it: no authorization + * block, an `oauth2` absent or `{}`, a `scopes` absent or `[]`. An `oauth2` or a `scopes` in any + * other shape is `unreadable`, and so is a list holding anything but scope text: `["repo", 5]` + * read as `["repo"]` would be a narrower grant than the one the vendor described, which is the same + * fail-open answer one element at a time. + */ +export type ScopesRequirement = + | { kind: "read"; scopes: string[] } + | { + kind: "unreadable"; + field: "requirements" | "authorization" | "oauth2" | "scopes"; + shape: string; + }; + +export function requiredScopesOf(item: unknown): ScopesRequirement { + const read = authorizationRequirementOf(item); + if (read.kind === "none") return { kind: "read", scopes: [] }; + if (read.kind === "unreadable") return read; + const oauth2 = read.authorization.oauth2; + if (oauth2 === undefined || oauth2 === null) { + return { kind: "read", scopes: [] }; + } + if (typeof oauth2 !== "object" || Array.isArray(oauth2)) { + return { kind: "unreadable", field: "oauth2", shape: shapeOf(oauth2) }; + } + const scopes = (oauth2 as Record).scopes; + if (scopes === undefined || scopes === null) { + return { kind: "read", scopes: [] }; + } + if (!Array.isArray(scopes)) { + return { kind: "unreadable", field: "scopes", shape: shapeOf(scopes) }; + } + const odd = scopes.findIndex( + (scope) => typeof scope !== "string" || scope.trim() === "", + ); + if (odd !== -1) { + return { + kind: "unreadable", + field: "scopes", + shape: `a list holding ${shapeOf(scopes[odd])}`, + }; + } + return { kind: "read", scopes: scopes as string[] }; +} + +/** + * One vendor STATUS field — `token_status`, `connection_status`, a mint's or a flow's `status` — + * read by the rule above: absent, unreadable, or its text FOLDED AND TRIMMED. + * + * NOT `textOf`, deliberately: `textOf` answers a blank string as absence, which is right for a + * label and wrong for a decision, and it does not fold. Each status reader once spelled its own + * `textOf(x)?.trim().toLowerCase()` (or forgot to), and the one that forgot answered a vendor-side + * change of case differently from the ones that did. + */ +export type VendorStatus = + | { kind: "absent" } + | { kind: "unreadable"; shape: string } + | { kind: "text"; text: string }; + +export function vendorStatusOf(value: unknown): VendorStatus { + if (value === undefined || value === null) return { kind: "absent" }; + if (typeof value !== "string" || value.trim() === "") { + return { kind: "unreadable", shape: shapeOf(value) }; + } + return { kind: "text", text: value.trim().toLowerCase() }; +} + +/** Where on the action a {@link ScopesRequirement} could not be read, in words. */ +function scopesFieldOf( + field: Extract["field"], +): string { + return field === "requirements" + ? "requirements" + : field === "authorization" + ? "authorization requirement" + : `authorization ${field}`; +} + +/** + * The OAuth scopes an action needs, for the tool the gate checks. * * NEVER `requirements.met`. The two sit in the same object and behave oppositely: `met` is a * per-person claim that answers TRUE FOR EVERYBODY when asked without a `user_id` (and this @@ -139,14 +337,18 @@ function record(value: unknown): Record | null { * Reading `met` here would record every person as connected to everything, and nothing else would * go red: the gate would pass, the call would be made, and the first sign would be somebody * else's mailbox. + * + * AN UNREADABLE SCOPES BLOCK REFUSES THE LISTING; IT IS NEVER `[]`. `[]` on a listed tool is what + * the gate reads as "the connection is the grant", so answering it for a block this deployment + * could not read would let any connection run an action whose real scopes nobody read. See + * {@link requiredScopesOf}. The refusal names the shape, never the vendor's value. */ -function scopesOf(requirements: unknown): string[] { - const scopes = record( - record(record(requirements)?.authorization)?.oauth2, - )?.scopes; - return Array.isArray(scopes) - ? scopes.filter((s): s is string => typeof s === "string") - : []; +function scopesOf(tool: Record, toolkit: string): string[] { + const read = requiredScopesOf(tool); + if (read.kind === "read") return read.scopes; + throw new Error( + `Arcade listed one of ${toolkit}'s actions with ${read.shape} where its ${scopesFieldOf(read.field)} belongs, and that is what says which permissions it needs. A shape this deployment cannot read is not "no permissions", so nothing was refreshed and the actions already recorded for this app are kept.`, + ); } function inputSchemaOf(input: unknown): Record { @@ -254,7 +456,7 @@ export async function listTools(connection: { typeof tool.description === "string" ? tool.description : "", inputSchema: inputSchemaOf(tool.input), ...classify(tool.metadata), - scopes: scopesOf(tool.requirements), + scopes: scopesOf(tool, toolkit), }, ]; }); diff --git a/server/tests/arcade-adapter-connections.test.ts b/server/tests/arcade-adapter-connections.test.ts index d55f7f11b..0de1b587b 100644 --- a/server/tests/arcade-adapter-connections.test.ts +++ b/server/tests/arcade-adapter-connections.test.ts @@ -759,7 +759,14 @@ describe("a connection the vendor reports as dead", () => { expect([...deleted].sort()).toEqual(["ac_1", "ac_2"]); }); - test("a status the vendor did not send, or sent blank, is SILENCE and still reads as live", async () => { + /* + * K2 SETTLED WHAT "BLANK" MEANS, ONCE, FOR EVERY VENDOR DECISION FIELD: a status the vendor did + * not send (or sent as `null`) is SILENCE and the row stands; a status sent BLANK is present and + * carries nothing, which is UNREADABLE — the judgement `authProvidersNamedBy` makes of a blank + * `provider_id` — and an unreadable status is not "live". This row once asserted the opposite for + * blank, which made the two readers disagree about the same wire value. + */ + test("a status the vendor did not send is SILENCE and still reads as live; a blank one does not", async () => { listing([ statusRow(1, "cpk-poc", null, { name: "Ada" }), statusRow(2, "google-openbot", "", { name: "Grace" }), @@ -769,10 +776,8 @@ describe("a connection the vendor reports as dead", () => { expect(await broker.accountName({ account, toolkit: "Github" })).toBe( "Ada", ); - expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(true); - expect(await broker.accountName({ account, toolkit: "Gmail" })).toBe( - "Grace", - ); + expect(await broker.isConnected({ account, toolkit: "Gmail" })).toBe(false); + expect(await broker.accountName({ account, toolkit: "Gmail" })).toBeNull(); }); test("the status is folded and trimmed, so ` ACTIVE ` is live and `Expired` is not", async () => { diff --git a/server/tests/arcade-authorize-status-engine.test.ts b/server/tests/arcade-authorize-status-engine.test.ts index 3d8a0039c..b51cd6a76 100644 --- a/server/tests/arcade-authorize-status-engine.test.ts +++ b/server/tests/arcade-authorize-status-engine.test.ts @@ -23,8 +23,13 @@ const account: AccountRef = { }; type EngineOptions = { - /** The `status` the mint answers with; `null` OMITS the field, as the wire may. */ - status: string | null; + /** + * The `status` the mint answers with; `null` OMITS the field, as the wire may. Anything else is + * serialized as it stands, so a status that is not text reaches the adapter as the wire sent it. + */ + status: unknown; + /** Send `"status": null` explicitly rather than omitting the key. */ + explicitNull?: true; /** Whether the mint answers a consent url beside it. */ url?: string | null; }; @@ -39,6 +44,7 @@ function startEngine(options: EngineOptions): Engine { if (url.pathname === "/v1/tools/authorize") { const body: Record = { id: "ar_mint1" }; if (options.status !== null) body.status = options.status; + else if (options.explicitNull) body.status = null; const consent = options.url === undefined ? "https://consent.example/go" @@ -115,8 +121,81 @@ describe("the mint reads the status Arcade answered with", () => { await expect(mint()).rejects.toBeInstanceOf(BrokerRefusalError); }); + /* + * D6. Every assertion above holds whether or not the status is FOLDED: an unfolded `" Completed "` + * is a spelling outside the allowlist and is refused anyway, so the old single row here could not + * fail on a deleted `.trim().toLowerCase()`. The rows below can: a padded or upper-cased member of + * the ALLOWLIST is handed on only when the read folds it, and the held-grant SENTENCE ("already") + * is reached only by a folded `completed`. + */ test("the status is read case- and space-insensitively, as every other comparison here is", async () => { engine({ status: " Completed " }); await expect(mint()).rejects.toBeInstanceOf(BrokerRefusalError); + await expect(mint()).rejects.toThrow(/already/i); + }); + + test("a padded, re-cased pending is still a consent to walk through", async () => { + engine({ status: " Pending " }); + expect(await mint()).toEqual({ + redirectUrl: "https://consent.example/go", + authorizationId: "ar_mint1", + }); + }); + + test("not_started — the other half of the allowlist, and what a first mint answers — is handed on", async () => { + engine({ status: "not_started" }); + expect(await mint()).toEqual({ + redirectUrl: "https://consent.example/go", + authorizationId: "ar_mint1", + }); + }); + + test("a padded, upper-cased not_started is handed on too", async () => { + engine({ status: " NOT_STARTED " }); + expect(await mint()).toEqual({ + redirectUrl: "https://consent.example/go", + authorizationId: "ar_mint1", + }); }); + + test("an explicit null status is silence, exactly like an omitted one", async () => { + engine({ status: null, explicitNull: true }); + expect(await mint()).toEqual({ + redirectUrl: "https://consent.example/go", + authorizationId: "ar_mint1", + }); + }); +}); + +/** + * L2-b. A STATUS THAT IS PRESENT AND IS NOT STATUS TEXT IS NOT SILENCE. It once read as silence — + * `textOf` answered `undefined` for it, and "silence is not a no" handed the bearer url beside it + * to a browser as a live redirect, after a status the code could not read. It is refused, as a + * DEFINITE refusal (a `BrokerRefusalError`), so the attempt is discarded and Authorize can be + * pressed again at once. + */ +describe("a mint status this deployment cannot read is not handed on", () => { + for (const [label, status] of [ + ["an object", {}], + ["false", false], + ["a number", 0], + ["a list", []], + ["a blank string", ""], + ["whitespace", " "], + ] as const) { + test(`${label} where the status belongs refuses, and the url is not returned`, async () => { + engine({ status }); + const outcome = await mint().then( + (minted) => ({ minted }), + (error: unknown) => ({ error }), + ); + expect("minted" in outcome).toBe(false); + expect((outcome as { error: unknown }).error).toBeInstanceOf( + BrokerRefusalError, + ); + expect(String((outcome as { error: Error }).error.message)).not.toContain( + "consent.example", + ); + }); + } }); diff --git a/server/tests/arcade-journey.integration.test.ts b/server/tests/arcade-journey.integration.test.ts index 4a0d4d972..f9ede7ced 100644 --- a/server/tests/arcade-journey.integration.test.ts +++ b/server/tests/arcade-journey.integration.test.ts @@ -212,15 +212,30 @@ const CATALOGUE: Record[] = [ action(DIARY, "DeleteEvent", METADATA.destructive), ]; -/** The same listed action as the vendor publishes it to an identity that has consented. */ -function granted(item: Record): Record { +/** + * The same listed action as the vendor publishes it when asked ABOUT an identity: with that + * identity's own `token_status` — `completed` once it has consented, `not_started` before. + * + * `token_status` AND NOT `status`. The authorization's `status` is the AUTH PROVIDER's standing + * (`inactive` / "Provider not found" in `fixtures/arcade-catalogue.json`), the same for everybody; + * this fixture once flipped it to `active` to mean "this person consented", and the grants screen + * read it that way — which is the K2 (A7c) defect: every action of a configured provider drawn as + * held. The catalogue listing (no `user_id`) carries no `token_status`, as the live one does not. + */ +function askedAbout( + item: Record, + consented: boolean, +): Record { const requirements = item.requirements as Record; const authorization = requirements.authorization as Record; return { ...item, requirements: { ...requirements, - authorization: { ...authorization, status: "active" }, + authorization: { + ...authorization, + token_status: consented ? "completed" : "not_started", + }, }, }; } @@ -296,11 +311,13 @@ function installVendor() { * A LISTING ASKED ABOUT SOMEBODY ANSWERS ABOUT THEM, which is the only difference the * `user_id` makes and the whole of what `grantsFor` is reading. `met` stays true on every * item either way (see `action`), so a step reading it instead of the authorization's own - * status would report this run as holding every grant before a single consent. + * `token_status` would report this run as holding every grant before a single consent. */ - query.user_id && - vendorConnections.some((r) => r.user_id === query.user_id) - ? granted(item) + query.user_id + ? askedAbout( + item, + vendorConnections.some((r) => r.user_id === query.user_id), + ) : item, ); const offset = query.offset ?? 0; diff --git a/server/tests/arcade-vendor-read-contract.test.ts b/server/tests/arcade-vendor-read-contract.test.ts index c32f84a96..5535ddd45 100644 --- a/server/tests/arcade-vendor-read-contract.test.ts +++ b/server/tests/arcade-vendor-read-contract.test.ts @@ -11,10 +11,18 @@ * that is present but not text reads as live (A7b), and the mint's allowlist test could not fail * on a missing `.trim().toLowerCase()` (D6). The tests never presented the shapes that show it. * - * THE RULE THE TABLE STATES. Absent (`missing`, `undefined`, and `null` where the vendor uses it - * for absence) reads as the field's documented default. A value that is PRESENT BUT NOT THE SHAPE - * THE FIELD HAS is unreadable, and unreadable FAILS CLOSED: the grants screen says `unknown`, a - * listing refuses, a connection is not live. Never authorized, never live, never `[]`-as-grant. + * THE RULE THE TABLE STATES — the one `vendorStatusOf` / `requiredScopesOf` / + * `authorizationRequirementOf` in `server/src/plugins/arcade.ts` implement. Absent (`missing`, + * `undefined`, `null`) reads as the field's documented default. A value that is PRESENT BUT NOT THE + * SHAPE THE FIELD HAS is unreadable, and unreadable FAILS CLOSED: the grants screen says `unknown`, + * a listing refuses, a connection is not live, a mint is refused. Never authorized, never live, + * never `[]`-as-grant, never a redirect. + * + * TWO READINGS THIS TABLE ONCE HELD APART, SETTLED (K2). `null` is ABSENCE on every field — C2's + * reading, which the vendor's own `requirements: null` evidences — so `connection_status: null` is + * live like a missing one, not "a value sent" (the A7b row that said otherwise is withdrawn). And a + * BLANK STRING is UNREADABLE on every decision field — C2b's reading of a blank `provider_id` — so + * `connection_status: ""` is not live (the source's "absent or empty reads as live" is withdrawn). * * EACH ROW IS DRIVEN THROUGH THE READER'S PUBLIC DOOR — `broker.grantsFor`, `broker.listApps`, * the transport's `listTools`, `broker.isConnected`, `broker.authorize` — over SDK prototype @@ -452,14 +460,46 @@ const ROWS: Row[] = [ ), // connectionIsLive — absent is live ("silence is not a no", documented in the source, and so is // an empty string); present but not text is the vendor saying something this cannot read. + // `null` is absence here as on every field, and a blank string is unreadable as on every + // decision field — see the header's "settled". ...sweep( CONNECTION_IS_LIVE, ["connection_status"], - { absent: "live", unreadable: "not-live", readableAsAbsent: ['""'] }, - "present but not text is not 'no status'", - ).map((row) => - // Explicit `null` is a value the vendor SENT on a connection row (A7b), not an omission. - row.shape === "null" ? { ...row, want: "not-live" } : row, + { absent: "live", unreadable: "not-live" }, + "present but not text (or blank) is not 'no status'", + ), + // The mint's own `status` (L2-b): absent is silence and the url stands; anything present that is + // not status text is NOT handed to a browser as a live redirect. + ...sweep( + MINT_STATUS, + ["status"], + { absent: "consent-next", unreadable: "refuses" }, + "a bearer url beside a status nobody could read is not a consent screen to send somebody to", + ), + // A scope list holding something that is not a scope is not the narrower list without it. + ...( + [ + [GRANT_STATE_OF, "unknown"], + [CATALOGUE_SCOPES_OF, "refuses"], + [TRANSPORT_SCOPES_OF, "refuses"], + ] as const + ).flatMap(([reader, want]) => + ( + [ + ['["repo",5]', ["repo", 5]], + ['["repo",""]', ["repo", ""]], + ['["repo",null]', ["repo", null]], + ] as const + ).map( + ([shape, value]): Row => ({ + reader, + field: [...AUTH, "oauth2", "scopes"], + shape, + value, + want, + why: "a partly unreadable scope list read as the readable part is a narrower grant than the one described", + }), + ), ), // Readable controls: the reader still READS. Without these a reader that answered the @@ -571,72 +611,12 @@ const ROWS: Row[] = [ // THE DEBT. Delete an entry when its reader is fixed; never add one. See the header. // --------------------------------------------------------------------------------------------- -const A7C = "A7c: missing/unreadable token_status falls back to status"; -const C2C = "C2c: unreadable oauth2/scopes collapses to []"; -const A7B = "A7b: present-but-not-text connection_status reads as live"; -const A7C_CASE = "A7c: grantStateOf skips .trim().toLowerCase()"; - -const KNOWN_FAIL_OPEN: Record = { - // grantStateOf · token_status — every shape authorizes, off the provider's `status: "active"`. - ...Object.fromEntries( - SHAPES.map(({ label }) => [ - `grantStateOf (arcade-adapter.ts) · requirements.authorization.token_status · ${label}`, - { now: "authorized [repo]", finding: A7C }, - ]), - ), - ...Object.fromEntries( - ["{}", "false", "0", '""'].map((label) => [ - `grantStateOf (arcade-adapter.ts) · requirements.authorization.oauth2.scopes · ${label}`, - { now: "authorized []", finding: C2C }, - ]), - ), - ...Object.fromEntries( - ["[]", "false", "0", '""'].map((label) => [ - `grantStateOf (arcade-adapter.ts) · requirements.authorization.oauth2 · ${label}`, - { now: "authorized []", finding: C2C }, - ]), - ), - ...Object.fromEntries( - ["{}", "false", "0", '""'].map((label) => [ - `scopesOf (arcade-adapter.ts) · requirements.authorization.oauth2.scopes · ${label}`, - { now: "unscoped", finding: C2C }, - ]), - ), - ...Object.fromEntries( - ["[]", "false", "0", '""'].map((label) => [ - `scopesOf (arcade-adapter.ts) · requirements.authorization.oauth2 · ${label}`, - { now: "unscoped", finding: C2C }, - ]), - ), - ...Object.fromEntries( - ["[]", "false", "0", '""'].map((label) => [ - `scopesOf (arcade.ts) · requirements.authorization · ${label}`, - { now: "scopes []", finding: C2C }, - ]), - ), - ...Object.fromEntries( - ["[]", "false", "0", '""'].map((label) => [ - `scopesOf (arcade.ts) · requirements.authorization.oauth2 · ${label}`, - { now: "scopes []", finding: C2C }, - ]), - ), - ...Object.fromEntries( - ["{}", "false", "0", '""'].map((label) => [ - `scopesOf (arcade.ts) · requirements.authorization.oauth2.scopes · ${label}`, - { now: "scopes []", finding: C2C }, - ]), - ), - ...Object.fromEntries( - ["null", "{}", "false", "0", "[]"].map((label) => [ - `connectionIsLive (arcade-adapter.ts) · connection_status · ${label}`, - { now: "live", finding: A7B }, - ]), - ), - [`grantStateOf (arcade-adapter.ts) · requirements.authorization.token_status · ${BESIDE_INACTIVE}`]: - { now: "needs-consent [repo]", finding: A7C_CASE }, - 'grantStateOf (arcade-adapter.ts) · requirements.authorization.token_status · "NOT_STARTED"': - { now: "unknown", finding: A7C_CASE }, -}; +/** + * EMPTY SINCE K2: the 43 entries L2 recorded (A7c ×10, C2c ×28, A7b ×5) were paid by giving every + * reader the one reading in `server/src/plugins/arcade.ts`. An entry added here is a reader that + * fails open written after that, which is what this gate exists to say. + */ +const KNOWN_FAIL_OPEN: Record = {}; // --------------------------------------------------------------------------------------------- // The checks. diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index aa57ceb3a..a1d956d45 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -46,7 +46,7 @@ server/tests/approvals-question-resume.integration.test.ts:250: error TS2741: Pr server/tests/approvals-question-resume.integration.test.ts:261: error TS2741: Property 'resumeWaiting' is missing in type 'Pick' but required in type 'Pick'. server/tests/approvals.test.ts:155: error TS2322: Type 'Promise<{ id: string; ownerUserId: string; botId: string; toolRef: string; effect: string; scope: string; revokedAt: Date | null; createdAt: Date; }[]>' is not assignable to type 'Promise'. server/tests/approvals.test.ts:296: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. -server/tests/arcade-authorize-status-engine.test.ts:52: error TS2322: Type 'number | undefined' is not assignable to type 'number'. +server/tests/arcade-authorize-status-engine.test.ts:58: error TS2322: Type 'number | undefined' is not assignable to type 'number'. server/tests/arcade-confirm-engine.test.ts:41: error TS2322: Type 'number | undefined' is not assignable to type 'number'. server/tests/arcade-connection-kind-engine.test.ts:85: error TS2322: Type 'number | undefined' is not assignable to type 'number'. server/tests/arcade-connection-kind-engine.test.ts:87: error TS2322: Type 'Engine | null' is not assignable to type 'Engine'. @@ -81,7 +81,7 @@ server/tests/component-decision.test.ts:30: error TS2769: No overload matches th server/tests/composio-connection-kinds.test.ts:1908: error TS2769: No overload matches this call. server/tests/composio-connection-kinds.test.ts:1910: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"key" | "none" | "consent" | "unreadable", Cell>'. server/tests/composio-connection-kinds.test.ts:1924: error TS2769: No overload matches this call. -server/tests/composio-connection-kinds.test.ts:1926: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"answered" | "unreachable" | "nothing" | "complained", Cell>'. +server/tests/composio-connection-kinds.test.ts:1926: error TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type 'Record<"nothing" | "answered" | "unreachable" | "complained", Cell>'. server/tests/composio-connection-kinds.test.ts:327: error TS2769: No overload matches this call. server/tests/composio-connection-kinds.test.ts:481: error TS2769: No overload matches this call. server/tests/composio-connections.test.ts:4417: error TS2769: No overload matches this call. From 3469999e28ba4ada4abf0ac978e585d4cf40fbdc Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 19:34:41 +0200 Subject: [PATCH 225/232] fix(arcade): load the app directory inside the vendor deadline "Browse Arcade" could never load. listApps read Arcade's whole tool directory (total_count 8,320) 100 at a time, sequentially: 84 pages at ~1.2 s each, ~100 s, under the 10 s VENDOR_CALL_TIMEOUT_MS that wraps the whole call. The browser got a 502 after 10,157 ms every time. listApps now reads pages of 1000 (DIRECTORY_PAGE_SIZE; limit=1000 measured accepted at ~1.8 s, limit=5000 rejected) and, once page one's total_count says how many pages follow, fetches the rest concurrently, at most 8 at a time (DIRECTORY_CONCURRENCY): today's 8 remaining pages in one wave, ~3.6 s. The deadline and the absence of a cache are unchanged on purpose. Answers are still consumed in offset order by the same loop, so a short page still ends the listing (queued pages never fire, fetched ones are discarded), an under-reported total still reads on sequentially, and seen < total still throws the incompleteness sentence. Call sites of pagedItems (behaviour changed only for the one that opts in through the new optional Listing.concurrency): - listApps -> opts in (page 1000, concurrency 8) - authProviderInstances -> unchanged (AUTH_PROVIDER_PAGE_SIZE 100, sequential) - connectionsFor -> unchanged (CONNECTION_PAGE_SIZE 100, sequential) - grantsFor -> unchanged (CATALOGUE_PAGE_SIZE 100, sequential) - authProvidersNamedBy -> unchanged (CATALOGUE_PAGE_SIZE 100, sequential) Tests: new arcade-directory-deadline-engine.test.ts serves the measured directory over a real socket with the measured latency (time scaled /10) and holds listApps to the scaled deadline and request pattern. Pinned listApps query shapes moved to the new page size in arcade-adapter.test.ts ([0,100,200] -> {limit:1000} at [0,1000,2000]) and the two catalogue cases in arcade-pagination-engine.test.ts (250-of-300 -> 2500-of-3000). Co-Authored-By: Claude Opus 5.5 --- server/src/plugins/arcade-adapter.ts | 141 +++++++++-- server/tests/arcade-adapter.test.ts | 33 ++- .../arcade-directory-deadline-engine.test.ts | 233 ++++++++++++++++++ server/tests/arcade-pagination-engine.test.ts | 21 +- 4 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 server/tests/arcade-directory-deadline-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index 1b251a029..c5b7c8f36 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -189,11 +189,41 @@ async function vendorCall(step: string, run: () => Promise): Promise { } } -/** Page size for the catalogue listing. A short page ends it, never `total_count`. */ +/** + * Page size for the PER-TOOLKIT action listings, `grantsFor` and `authProvidersNamedBy`. A short page ends it, never `total_count`. The whole-directory listing behind + * `listApps` does NOT use this: see {@link DIRECTORY_PAGE_SIZE}. + */ const CATALOGUE_PAGE_SIZE = 100; /** A runaway guard, not a bound. */ const CATALOGUE_MAX_PAGES = 1000; +/** + * Page size for the WHOLE-DIRECTORY listing `listApps` reads, which is the "Browse Arcade" screen. + * + * THE ARITHMETIC, MEASURED AGAINST THE LIVE API so the next person changing this does not re-probe: + * - Arcade's directory reported `total_count` **8,320** tools. + * - `limit=1000` is accepted (1,000 items in ~1.77 s); `limit=5000` is REJECTED (empty answer in + * ~0.17 s), so **1000 is at or near the vendor's ceiling** — do not raise this past it. + * - A page costs **~1.8 s** at limit 1000 (~1.2 s at 100, ~1.8 s at 250): latency, not size, + * dominates, so fewer larger pages are nearly free. + * + * At the old page size of 100 that was 84 SEQUENTIAL pages, ~100 s, against a 10 s + * `VENDOR_CALL_TIMEOUT_MS` that wraps the WHOLE `listApps` — so the directory could never load. At + * 1000 it is 9 pages: one, then the remaining 8 at once under {@link DIRECTORY_CONCURRENCY}, ~3.6 s. + */ +const DIRECTORY_PAGE_SIZE = 1000; + +/** + * How many directory pages are in flight at once after page one has said how many there are. + * + * EIGHT, because it covers today's directory in ONE wave (8,320 tools → 8 pages after the first) + * while still being a bound: a `total_count` of a million fires eight requests at a time, never a + * thousand. The deadline arithmetic is `~1.8 s × (1 + ⌈remaining / 8⌉)`, which stays under the 10 s + * `VENDOR_CALL_TIMEOUT_MS` up to 32 remaining pages — a directory of roughly 33,000 tools, four times + * today's. Past that the listing will time out again, and that is the number to revisit. + */ +const DIRECTORY_CONCURRENCY = 8; + /** * WHAT ARCADE ITSELF SENT: the parsed response body, and never the SDK's page object. * @@ -347,8 +377,48 @@ type Listing = { * LENGTH was not, and a reader sent looking for a broken list would find a well-formed one. */ unreadableTotal: (shown: string) => string; + /** + * How many pages may be in flight at once AFTER PAGE ONE. Absent or 1 is the strictly sequential + * read every listing but `listApps` uses. Above 1, page one's `total_count` decides which further + * offsets are fetched ahead, at most this many at a time; see {@link pagedItems}. + */ + concurrency?: number; }; +/** + * `run`, at most `limit` at a time, and NOT AT ALL once `abandoned()` says the reader has stopped. + * + * The second half is what keeps a lying `total_count` from costing anything: a listing that claims + * a million rows and then ends on page two must not go on firing the 998 requests queued behind it. + */ +function bounded( + limit: number, + abandoned: () => boolean, +): (run: () => Promise) => Promise { + let active = 0; + const waiting: (() => void)[] = []; + return (run: () => Promise) => + new Promise((resolve, reject) => { + const start = () => { + if (abandoned()) { + resolve(undefined); + waiting.shift()?.(); + return; + } + active += 1; + // Through an executor, so a call that throws synchronously is a rejection like any other. + new Promise((answered) => answered(run())) + .then(resolve, reject) + .finally(() => { + active -= 1; + waiting.shift()?.(); + }); + }; + if (active < limit) start(); + else waiting.push(start); + }); +} + /** * ONE LISTING READ TO ITS END, as a stream of items. * @@ -365,25 +435,63 @@ type Listing = { * * THE INCOMPLETENESS THROW IS THE LAST THING THIS YIELDS, after every item, so a caller that folds * items as they arrive still never sees a short listing finish as a whole one. + * + * FETCHING AHEAD ({@link Listing.concurrency}) CHANGES WHEN A PAGE IS ASKED FOR, NEVER HOW IT IS + * READ. Page one is asked for alone; its `total_count` names the offsets that should exist, and + * those are requested at once under the bound. The answers are then consumed IN OFFSET ORDER by + * exactly the loop below, so every rule above still holds page by page: a short page still ends the + * listing (the pages fetched past it are discarded unread and the queued ones never fire), a total + * that UNDER-reports still reads on sequentially from where the prefetch ran out, and `seen < total` + * still throws. A page ahead that FAILED fails the listing when the loop reaches it, and not before. + * Without a readable total on page one there is nothing to fetch ahead of, and it reads sequentially. */ async function* pagedItems(listing: Listing): AsyncGenerator { let seen = 0; let total: number | undefined; let ended = false; - for (let index = 0; index < listing.maxPages; index += 1) { - const answer = await listing.page(seen); - const items = sentItemsOf(answer); - if (!Array.isArray(items)) throw new Error(listing.malformed); - yield* items; - seen += items.length; - total = highestTotal( - total, - reportedTotalOf(answer, listing.unreadableTotal), - ); - if (items.length < listing.pageSize) { - ended = true; - break; + let stopped = false; + const ahead = new Map>(); + try { + for (let index = 0; index < listing.maxPages; index += 1) { + const fetched = ahead.get(seen); + ahead.delete(seen); + const answer = await (fetched ?? listing.page(seen)); + const items = sentItemsOf(answer); + if (!Array.isArray(items)) throw new Error(listing.malformed); + yield* items; + seen += items.length; + total = highestTotal( + total, + reportedTotalOf(answer, listing.unreadableTotal), + ); + if (items.length < listing.pageSize) { + ended = true; + break; + } + if ( + index === 0 && + total !== undefined && + (listing.concurrency ?? 1) > 1 + ) { + const slot = bounded(listing.concurrency ?? 1, () => stopped); + // `floor`, not `ceil - 1`: a total that is an exact multiple of the page size still needs + // the EMPTY page after it, because a short page — never the total — is what ends a listing. + const last = Math.min( + Math.floor(total / listing.pageSize), + listing.maxPages - 1, + ); + for (let page = 1; page <= last; page += 1) { + const offset = page * listing.pageSize; + const pending = slot(() => listing.page(offset)); + // Observed here so a page fetched past the end — or past a failure the loop throws on + // first — never surfaces as an unhandled rejection. The loop still awaits the original. + pending.catch(() => undefined); + ahead.set(offset, pending); + } + } } + } finally { + stopped = true; } if (!ended) throw new Error(listing.unending); if (total !== undefined && seen < total) { @@ -554,11 +662,12 @@ async function listApps(client: Arcade): Promise { }; const counts = new Map(); for await (const item of pagedItems({ - pageSize: CATALOGUE_PAGE_SIZE, + pageSize: DIRECTORY_PAGE_SIZE, maxPages: CATALOGUE_MAX_PAGES, + concurrency: DIRECTORY_CONCURRENCY, page: (offset) => vendorCall("the app catalogue listing", () => - client.tools.list({ limit: CATALOGUE_PAGE_SIZE, offset }), + client.tools.list({ limit: DIRECTORY_PAGE_SIZE, offset }), ), malformed: "Arcade did not answer with a tool list, so its app catalogue could not be read.", diff --git a/server/tests/arcade-adapter.test.ts b/server/tests/arcade-adapter.test.ts index ef06e3016..d5ffb699c 100644 --- a/server/tests/arcade-adapter.test.ts +++ b/server/tests/arcade-adapter.test.ts @@ -69,29 +69,36 @@ describe("createArcadeClient", () => { }); test("listApps counts actions across the FULL paged listing", async () => { + // Pages of 1000 — the directory's page size since the "Browse Arcade" deadline fix (see + // `DIRECTORY_PAGE_SIZE`); this pinned [0, 100, 200] at the old size of 100. Pages are served BY + // OFFSET, not by call order, because pages two and three are now asked for concurrently. const pages: [string, number][][] = [ - [["GithubApi", 100]], - [["GithubApi", 100]], + [["GithubApi", 1000]], + [["GithubApi", 1000]], [ - ["GithubApi", 40], - ["Github", 10], + ["GithubApi", 400], + ["Github", 100], ], ]; - const asked: number[] = []; + const asked: { limit: number; offset: number }[] = []; stub(toolsProto(), "list", (...args: unknown[]) => { - const q = args[0] as { offset: number; toolkit?: string }; - asked.push(q.offset); - const page = pages[asked.length - 1] ?? []; + const q = args[0] as { limit: number; offset: number; toolkit?: string }; + asked.push({ limit: q.limit, offset: q.offset }); + const page = pages[q.offset / 1000] ?? []; const items = page.flatMap(([tk, n]) => Array.from({ length: n }, (_, i) => toolOf(tk, i)), ); - return Promise.resolve({ items, total_count: 250, offset: q.offset }); + return Promise.resolve({ items, total_count: 2500, offset: q.offset }); }); const apps = await createArcadeClient("never-dialled").broker.listApps(); - expect(asked).toEqual([0, 100, 200]); - expect(apps.find((a) => a.slug === "GithubApi")?.actionCount).toBe(240); - expect(apps.find((a) => a.slug === "Github")?.actionCount).toBe(10); - expect(apps.reduce((n, a) => n + a.actionCount, 0)).toBe(250); + expect(asked).toEqual([ + { limit: 1000, offset: 0 }, + { limit: 1000, offset: 1000 }, + { limit: 1000, offset: 2000 }, + ]); + expect(apps.find((a) => a.slug === "GithubApi")?.actionCount).toBe(2400); + expect(apps.find((a) => a.slug === "Github")?.actionCount).toBe(100); + expect(apps.reduce((n, a) => n + a.actionCount, 0)).toBe(2500); }); test("a REAL APIError through execute reaches callTool's authorize sentence", async () => { diff --git a/server/tests/arcade-directory-deadline-engine.test.ts b/server/tests/arcade-directory-deadline-engine.test.ts new file mode 100644 index 000000000..515bd666f --- /dev/null +++ b/server/tests/arcade-directory-deadline-engine.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import { withVendorDeadline } from "../src/plugins/route-groups/context"; + +/** + * "BROWSE ARCADE" MUST LOAD INSIDE ITS DEADLINE AGAINST A DIRECTORY OF ARCADE'S REAL SIZE. + * + * Every other `listApps` test serves a handful of rows, so nothing ever asked whether the listing + * finishes in time. Against the live API it could not: 8,320 tools read 100 at a time is 84 + * SEQUENTIAL pages at ~1.2 s each, ~100 s, and the route wraps the whole of `listApps` in the 10 s + * `VENDOR_CALL_TIMEOUT_MS` — the browser got a 502 after 10,157 ms, every time. + * + * So this engine serves THE MEASURED DIRECTORY over a real socket, with the MEASURED LATENCY, and + * holds the listing to the deadline. Time is scaled down ×10 throughout so the suite stays fast; + * the RATIO between page latency and deadline is the live one: + * + * | measured live | here (÷10) | + * |----------------------------------------|--------------| + * | `total_count` 8,320 | 8,320 (same) | + * | ~1.19 s per page at limit ≤ 100 | 119 ms | + * | ~1.8 s per page at limit 250…1000 | 180 ms | + * | limit above 1000 REJECTED, empty body | same | + * | `VENDOR_CALL_TIMEOUT_MS` 10,000 ms | 1,000 ms | + */ + +const SCALE = 10; +/** `route-groups/context.ts`'s `VENDOR_CALL_TIMEOUT_MS`, scaled. */ +const DEADLINE_MS = 10_000 / SCALE; +const DIRECTORY = 8_320; +const TOOLKIT_SIZE = 52; // 8,320 / 52 = 160 toolkits + +const toolRow = (n: number) => { + const toolkit = `Kit${Math.floor(n / TOOLKIT_SIZE)}`; + return { + qualified_name: `${toolkit}.Action${n}`, + description: "", + toolkit: { name: toolkit, description: "" }, + input: { parameters: [] }, + }; +}; + +type Asked = { + limit: number; + offset: number; + /** How many requests were in flight at the moment this one arrived, itself included. */ + inFlightAtStart: number; + /** How many requests had already been ANSWERED when this one arrived. */ + answeredBefore: number; +}; + +type Engine = { + asked: Asked[]; + maxInFlight: () => number; + stop: () => void; +}; + +type EngineOptions = { + /** What `total_count` says on every page. */ + claimed: number; + /** How many rows the engine actually serves, which may be fewer — or more — than it claims. */ + served?: number; + /** Per-request latency in ms at limit ≤ 100, and above it. Defaults are the live ones, ÷10. */ + latency?: { small: number; large: number }; +}; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +function startEngine(options: EngineOptions): Engine { + const asked: Asked[] = []; + const latency = options.latency ?? { + small: 1190 / SCALE, + large: 1800 / SCALE, + }; + const served = options.served ?? options.claimed; + let inFlight = 0; + let peak = 0; + let answered = 0; + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname !== "/v1/tools") + return new Response("not found", { status: 404 }); + const offset = Number(url.searchParams.get("offset") ?? "0"); + const limit = Number(url.searchParams.get("limit") ?? "25"); + inFlight += 1; + peak = Math.max(peak, inFlight); + asked.push({ + limit, + offset, + inFlightAtStart: inFlight, + answeredBefore: answered, + }); + try { + // MEASURED: limit=5000 answered an EMPTY response in ~0.17 s. A page size past the + // vendor's ceiling must fail here exactly as it fails live. + if (limit > 1000) { + await sleep(170 / SCALE); + return new Response("", { status: 200 }); + } + await sleep(limit <= 100 ? latency.small : latency.large); + const items = []; + for (let n = offset; n < Math.min(offset + limit, served); n++) + items.push(toolRow(n)); + return Response.json({ + items, + offset, + limit, + page_count: items.length, + total_count: options.claimed, + }); + } finally { + inFlight -= 1; + answered += 1; + } + }, + }); + process.env.ARCADE_BASE_URL = `http://localhost:${server.port}`; + return { asked, maxInFlight: () => peak, stop: () => server.stop(true) }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(options: EngineOptions): Engine { + running = startEngine(options); + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +/** `listApps` exactly as the "Browse Arcade" route calls it: under the vendor deadline. */ +const browse = () => + withVendorDeadline( + () => createArcadeClient("never-dialled").broker.listApps(), + DEADLINE_MS, + ); + +describe("the Arcade directory loads inside the vendor deadline at its real size", () => { + test("8,320 tools are listed in full before the deadline", async () => { + const e = engine({ claimed: DIRECTORY }); + const started = performance.now(); + + const apps = await browse(); + + const took = performance.now() - started; + expect(apps.reduce((n, app) => n + app.actionCount, 0)).toBe(DIRECTORY); + expect(apps.length).toBe(DIRECTORY / TOOLKIT_SIZE); + // Comfortably inside, not merely inside: one page alone, then one wave of the rest. + expect(took).toBeLessThan(DEADLINE_MS); + expect(e.asked.length).toBe(9); + }); + + test("page one is asked for alone, then every remaining page at once", async () => { + const e = engine({ claimed: DIRECTORY }); + + await browse(); + + const [first, ...rest] = e.asked; + // Page one first, alone — its `total_count` is what says how many pages follow. + expect(first).toEqual({ + limit: 1000, + offset: 0, + inFlightAtStart: 1, + answeredBefore: 0, + }); + // Every other page, by offset, at the same page size, and none of them twice. + expect(rest.map((one) => one.offset).sort((a, b) => a - b)).toEqual([ + 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, + ]); + for (const one of rest) expect(one.limit).toBe(1000); + // ...all asked for AFTER page one answered, and BEFORE any of them answered: one wave. + for (const one of rest) expect(one.answeredBefore).toBe(1); + expect(e.maxInFlight()).toBe(8); + }); + + test("a bigger directory is still fetched at most eight pages at a time", async () => { + // 20,000 tools: 19 pages after the first, so the bound — not the directory — decides the peak. + const e = engine({ claimed: 20_000, latency: { small: 5, large: 20 } }); + + const apps = await browse(); + + expect(apps.reduce((n, app) => n + app.actionCount, 0)).toBe(20_000); + expect(e.asked.length).toBe(21); + expect(e.maxInFlight()).toBe(8); + }); +}); + +describe("fetching ahead does not weaken the incompleteness check", () => { + test("a directory that claims 8,320 and serves 8,000 is refused", async () => { + engine({ claimed: DIRECTORY, served: 8000 }); + + await expect(browse()).rejects.toThrow( + /listed 8000 of 8320 actions, so its app catalogue is incomplete/, + ); + }); + + test("a short page in the MIDDLE ends the listing, and the pages fetched past it are not counted", async () => { + // The page at offset 4000 holds 500 rows; the five pages fetched ahead past it hold nothing. + // A reader that summed every answer would still see 4500 — but one that kept reading past the + // short page would be trusting a listing that already said it was over. + engine({ claimed: DIRECTORY, served: 4500 }); + + await expect(browse()).rejects.toThrow( + /listed 4500 of 8320 actions, so its app catalogue is incomplete/, + ); + }); + + test("a total that UNDER-reports is read on past, sequentially, to the real end", async () => { + // Claims 2000, serves 3500: the prefetch covers offsets 1000 and 2000, both full, so the + // listing must go on asking — a short page ends it, never the total. + const e = engine({ + claimed: 2000, + served: 3500, + latency: { small: 5, large: 20 }, + }); + + const apps = await browse(); + + expect(apps.reduce((n, app) => n + app.actionCount, 0)).toBe(3500); + expect(e.asked.map((one) => one.offset).sort((a, b) => a - b)).toEqual([ + 0, 1000, 2000, 3000, + ]); + }); +}); diff --git a/server/tests/arcade-pagination-engine.test.ts b/server/tests/arcade-pagination-engine.test.ts index df0ed1d68..63ed9053e 100644 --- a/server/tests/arcade-pagination-engine.test.ts +++ b/server/tests/arcade-pagination-engine.test.ts @@ -458,35 +458,40 @@ describe("the action listing's total_count cross-check survives a short final pa expect((await listTools({ url: "arcade://Gmail" })).length).toBe(250); }); + // THE CATALOGUE'S TWO CASES ARE SCALED ×10 FROM THE TRANSPORT'S: `listApps` reads the directory in + // pages of 1000 (`DIRECTORY_PAGE_SIZE`), so 250-of-300 would be ONE short page with the total + // dropped from it — no second page and nothing to cross-check, which is not the case under test. + // At 2500-of-3000 page one carries the claim and the rest are fetched AHEAD, concurrently, so + // these also hold that fetching ahead did not weaken the incompleteness check. test("the catalogue refuses a truncated listing too", async () => { engine({ connections: 0, connectionTotal: true, - toolsClaimed: 300, - toolsServed: 250, + toolsClaimed: 3000, + toolsServed: 2500, }); const { broker } = createArcadeClient("never-dialled"); await expect(broker.listApps()).rejects.toThrow( - /listed 250 of 300 actions, so its app catalogue is incomplete/, + /listed 2500 of 3000 actions, so its app catalogue is incomplete/, ); }); test("a later page revising the count DOWN does not erase the first page's claim", async () => { - // Page one claims 300, page two revises it to 250, and 250 is all the engine serves. Keeping + // Page one claims 3000, page two revises it to 2500, and 2500 is all the engine serves. Keeping // the LAST total any page reported — rather than the highest — makes the truncation agree with // itself and the listing commits as a complete one. engine({ connections: 0, connectionTotal: true, - toolsClaimed: 300, - toolsServed: 250, - toolsLaterTotal: 250, + toolsClaimed: 3000, + toolsServed: 2500, + toolsLaterTotal: 2500, }); const { broker } = createArcadeClient("never-dialled"); await expect(broker.listApps()).rejects.toThrow( - /listed 250 of 300 actions, so its app catalogue is incomplete/, + /listed 2500 of 3000 actions, so its app catalogue is incomplete/, ); }); From 6b25d8c471494a3a0369e8bfeee6437e67745afd Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 19:45:24 +0200 Subject: [PATCH 226/232] fix(arcade): read a large toolkit's action listing inside the vendor deadline GithubApi publishes 818 actions (measured live, the largest of Arcade's 129 toolkits). grantsFor and authProvidersNamedBy read it 100 at a time, sequentially: 9 pages at ~1.19 s, ~10.7 s, past the 10 s VENDOR_CALL_TIMEOUT_MS around the grants screen and Disconnect. Both listings now opt into the bounded fetch-ahead 3469999e added to pagedItems (Listing.concurrency) via a new CATALOGUE_CONCURRENCY = 8: page one alone, then the other 8 pages in one wave, ~2.4 s. CATALOGUE_PAGE_SIZE stays 100 so every toolkit over 100 actions still crosses page boundaries and the short-page / seen --- server/src/plugins/arcade-adapter.ts | 39 +- ...de-toolkit-listing-deadline-engine.test.ts | 340 ++++++++++++++++++ 2 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 server/tests/arcade-toolkit-listing-deadline-engine.test.ts diff --git a/server/src/plugins/arcade-adapter.ts b/server/src/plugins/arcade-adapter.ts index c5b7c8f36..be67c89fd 100644 --- a/server/src/plugins/arcade-adapter.ts +++ b/server/src/plugins/arcade-adapter.ts @@ -190,10 +190,40 @@ async function vendorCall(step: string, run: () => Promise): Promise { } /** - * Page size for the PER-TOOLKIT action listings, `grantsFor` and `authProvidersNamedBy`. A short page ends it, never `total_count`. The whole-directory listing behind - * `listApps` does NOT use this: see {@link DIRECTORY_PAGE_SIZE}. + * Page size for the PER-TOOLKIT action listings, `grantsFor` and `authProvidersNamedBy`. A short + * page ends it, never `total_count`. The whole-directory listing behind `listApps` does NOT use + * this: see {@link DIRECTORY_PAGE_SIZE}. + * + * THE MEASURED WORST CASE, so the next person does not re-scan the API: across Arcade's directory + * (8,320 tools, 129 toolkits) the largest toolkit is **`GithubApi`, 818 actions** — then + * `PosthogApi` 766, `DatadogApi` 588, `ZohoBooksApi` 511, `HubspotCrmApi` 453. At 100 a page + * costs ~1.19 s, so `GithubApi` read SEQUENTIALLY was 9 pages, ~10.7 s, past the 10 s + * `VENDOR_CALL_TIMEOUT_MS` that wraps the grants screen and Disconnect alike: neither could load. + * Under {@link CATALOGUE_CONCURRENCY} it is page one, then the other 8 in one wave, ~2.4 s. + * + * KEPT AT 100 RATHER THAN RAISED TO THE DIRECTORY'S 1000, deliberately. Latency, not size, + * dominates (~1.19 s at 100, ~1.8 s at 1000), so two waves of 100 cost about what one page of + * 1000 does. At 1000 every toolkit Arcade publishes would be ONE page, and the paths that make a + * per-toolkit read trustworthy — a short page ending it, `seen < total` judged across pages, a + * provider disagreeing with itself past page one — would stop being exercised by any real toolkit + * while still being the only defence when one grows. At 100, every toolkit over 100 actions + * crosses page boundaries on every read, so the completeness check stays a live check. */ const CATALOGUE_PAGE_SIZE = 100; + +/** + * How many per-toolkit action pages are in flight at once after page one has said how many there + * are, through the same opt-in {@link Listing.concurrency} the directory uses. + * + * EIGHT, because it covers `GithubApi` (818 actions → 8 pages after the first) in ONE wave while + * still bounding what a lying `total_count` can fire. The deadline arithmetic is + * `~1.19 s × (1 + ⌈remaining / 8⌉)`: under 10 s up to 56 remaining pages, a toolkit of ~5,700 + * actions, for the grants screen. Disconnect spends ~3 more sequential calls after this listing + * (auth providers, connections, the DELETE; ~1.2 s each, not measured separately), which leaves + * room for 32 remaining pages, a toolkit of ~3,300 actions — four times `GithubApi`. Past that is + * the number to revisit. + */ +const CATALOGUE_CONCURRENCY = 8; /** A runaway guard, not a bound. */ const CATALOGUE_MAX_PAGES = 1000; @@ -379,7 +409,8 @@ type Listing = { unreadableTotal: (shown: string) => string; /** * How many pages may be in flight at once AFTER PAGE ONE. Absent or 1 is the strictly sequential - * read every listing but `listApps` uses. Above 1, page one's `total_count` decides which further + * read the connection and auth-provider listings use. `listApps` and the per-toolkit action + * listings (`grantsFor`, `authProvidersNamedBy`) turn it on. Above 1, page one's `total_count` decides which further * offsets are fetched ahead, at most this many at a time; see {@link pagedItems}. */ concurrency?: number; @@ -1240,6 +1271,7 @@ async function grantsFor( for await (const item of pagedItems({ pageSize: CATALOGUE_PAGE_SIZE, maxPages: CATALOGUE_MAX_PAGES, + concurrency: CATALOGUE_CONCURRENCY, page: (offset) => vendorCall(`the action listing for ${toolkit}`, () => client.tools.list({ @@ -1394,6 +1426,7 @@ async function authProvidersNamedBy( for await (const item of pagedItems({ pageSize: CATALOGUE_PAGE_SIZE, maxPages: CATALOGUE_MAX_PAGES, + concurrency: CATALOGUE_CONCURRENCY, page: (offset) => vendorCall(`the action listing for ${toolkit}`, () => client.tools.list({ toolkit, limit: CATALOGUE_PAGE_SIZE, offset }), diff --git a/server/tests/arcade-toolkit-listing-deadline-engine.test.ts b/server/tests/arcade-toolkit-listing-deadline-engine.test.ts new file mode 100644 index 000000000..2a2f4ae30 --- /dev/null +++ b/server/tests/arcade-toolkit-listing-deadline-engine.test.ts @@ -0,0 +1,340 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { useArcadeClient } from "../src/plugins/arcade"; +import { createArcadeClient } from "../src/plugins/arcade-adapter"; +import { withVendorDeadline } from "../src/plugins/route-groups/context"; +import type { AccountRef } from "../src/plugins/shared-accounts"; + +/** + * ONE LARGE TOOLKIT MUST BE READABLE INSIDE ITS DEADLINE: THE GRANTS SCREEN AND DISCONNECT. + * + * The sibling of `arcade-directory-deadline-engine.test.ts`. That file holds the WHOLE directory to + * the deadline; this one holds the PER-TOOLKIT action listing — read by `grantsFor` (the grants + * screen) and by `authProvidersNamedBy` (behind `authProviderOf`, `isConnected` and `revoke`) — to + * the same deadline, at the size of the largest toolkit Arcade publishes. + * + * MEASURED against the live directory (8,320 tools across 129 toolkits): `GithubApi` has **818** + * actions. Read 100 at a time, one after another, that is 9 sequential pages at ~1.19 s each, + * ~10.7 s — past the 10 s `VENDOR_CALL_TIMEOUT_MS` every one of those routes is wrapped in. The + * grants screen for one of the most likely apps anybody adds could not load, and Disconnect for it + * could not even start. + * + * Time is scaled ×10 as in the directory test; the RATIO between page latency and deadline is the + * live one: + * + * | measured live | here (÷10) | + * |----------------------------------------|--------------| + * | `GithubApi` 818 actions | 818 (same) | + * | ~1.19 s per page at limit ≤ 100 | 119 ms | + * | ~1.8 s per page at limit 250…1000 | 180 ms | + * | limit above 1000 REJECTED, empty body | same | + * | `VENDOR_CALL_TIMEOUT_MS` 10,000 ms | 1,000 ms | + * + * The admin listings and the DELETE on the Disconnect path were not measured separately; they are + * served at the same small-page latency, which is the conservative reading. + */ + +const SCALE = 10; +/** `route-groups/context.ts`'s `VENDOR_CALL_TIMEOUT_MS`, scaled. */ +const DEADLINE_MS = 10_000 / SCALE; +/** The measured largest toolkit in Arcade's directory. */ +const GITHUB_API = 818; +const TOOLKIT = "GithubApi"; + +const account: AccountRef = { + holder: "person", + userId: "u1", + vendorUserId: "vendor-u1", +}; + +const toolRow = (n: number) => ({ + qualified_name: `${TOOLKIT}.Action${n}`, + description: "", + toolkit: { name: TOOLKIT, description: "" }, + input: { parameters: [] }, + requirements: { + authorization: { provider_id: "github", oauth2: { scopes: [] } }, + }, +}); + +type Asked = { + toolkit: string | null; + userId: string | null; + limit: number; + offset: number; + /** How many tool-listing requests were in flight when this one arrived, itself included. */ + inFlightAtStart: number; + /** How many tool-listing requests had already been ANSWERED when this one arrived. */ + answeredBefore: number; +}; + +type Engine = { + /** Every request to `/v1/tools`, in arrival order. */ + asked: Asked[]; + maxInFlight: () => number; + deletes: () => number; + stop: () => void; +}; + +type EngineOptions = { + /** What `total_count` says on every page of the action listing. */ + claimed: number; + /** How many actions the engine actually serves, which may be fewer — or more — than it claims. */ + served?: number; + /** Per-request latency in ms at limit ≤ 100, and above it. Defaults are the live ones, ÷10. */ + latency?: { small: number; large: number }; +}; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +function startEngine(options: EngineOptions): Engine { + const asked: Asked[] = []; + const latency = options.latency ?? { + small: 1190 / SCALE, + large: 1800 / SCALE, + }; + const served = options.served ?? options.claimed; + let inFlight = 0; + let peak = 0; + let answered = 0; + let deleted = 0; + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + const offset = Number(url.searchParams.get("offset") ?? "0"); + const limit = Number(url.searchParams.get("limit") ?? "25"); + + if (url.pathname.startsWith("/v1/admin/user_connections/")) { + await sleep(latency.small); + deleted += 1; + return new Response(null, { status: 204 }); + } + if (url.pathname === "/v1/admin/user_connections") { + await sleep(latency.small); + const items = + offset === 0 + ? [ + { + id: "at_1", + connection_id: "ac_1", + connection_status: "active", + provider_id: "github-openbot", + provider_type: "oauth2", + provider_user_info: { name: "person one" }, + scopes: [], + user_id: account.vendorUserId, + }, + ] + : []; + return Response.json({ + items, + offset, + limit, + page_count: items.length, + total_count: 1, + }); + } + if (url.pathname === "/v1/admin/auth_providers") { + await sleep(latency.small); + const items = + offset === 0 + ? [{ id: "github-openbot", provider_id: "github", type: "oauth2" }] + : []; + return Response.json({ + items, + offset, + limit, + page_count: items.length, + total_count: 1, + }); + } + if (url.pathname !== "/v1/tools") + return new Response("not found", { status: 404 }); + + inFlight += 1; + peak = Math.max(peak, inFlight); + asked.push({ + toolkit: url.searchParams.get("toolkit"), + userId: url.searchParams.get("user_id"), + limit, + offset, + inFlightAtStart: inFlight, + answeredBefore: answered, + }); + try { + // MEASURED: limit=5000 answered an EMPTY response in ~0.17 s. A page size past the + // vendor's ceiling must fail here exactly as it fails live. + if (limit > 1000) { + await sleep(170 / SCALE); + return new Response("", { status: 200 }); + } + await sleep(limit <= 100 ? latency.small : latency.large); + const items = []; + for (let n = offset; n < Math.min(offset + limit, served); n++) + items.push(toolRow(n)); + return Response.json({ + items, + offset, + limit, + page_count: items.length, + total_count: options.claimed, + }); + } finally { + inFlight -= 1; + answered += 1; + } + }, + }); + process.env.ARCADE_BASE_URL = `http://localhost:${server.port}`; + return { + asked, + maxInFlight: () => peak, + deletes: () => deleted, + stop: () => server.stop(true), + }; +} + +let running: Engine | null = null; +const wasBaseURL = process.env.ARCADE_BASE_URL; + +function engine(options: EngineOptions): Engine { + running = startEngine(options); + return running; +} + +afterEach(() => { + running?.stop(); + running = null; + useArcadeClient(null); + if (wasBaseURL === undefined) delete process.env.ARCADE_BASE_URL; + else process.env.ARCADE_BASE_URL = wasBaseURL; +}); + +/** Each broker member exactly as its route calls it: under the vendor deadline. */ +const underDeadline = ( + run: (broker: ReturnType["broker"]) => Promise, +) => + withVendorDeadline( + () => run(createArcadeClient("never-dialled").broker), + DEADLINE_MS, + ); + +const grants = () => + underDeadline((broker) => broker.grantsFor({ account, toolkit: TOOLKIT })); + +/** The offsets asked for after page one, in offset order. */ +const restOffsets = (e: Engine) => + e.asked + .slice(1) + .map((one) => one.offset) + .sort((a, b) => a - b); + +describe("the largest Arcade toolkit is read inside the vendor deadline", () => { + test("the grants screen lists all 818 GithubApi actions before the deadline", async () => { + const e = engine({ claimed: GITHUB_API }); + const started = performance.now(); + + const held = await grants(); + + const took = performance.now() - started; + expect(held.length).toBe(GITHUB_API); + expect(took).toBeLessThan(DEADLINE_MS); + // Still nine pages of 100 — the page size did not change, only when the pages are asked for. + expect(e.asked.length).toBe(9); + for (const one of e.asked) { + expect(one.toolkit).toBe(TOOLKIT); + expect(one.userId).toBe(account.vendorUserId); + expect(one.limit).toBe(100); + } + }); + + test("page one is asked for alone, then the other eight pages at once", async () => { + const e = engine({ claimed: GITHUB_API }); + + await grants(); + + // Page one first, alone — its `total_count` is what says how many pages follow. + expect(e.asked[0]).toMatchObject({ + limit: 100, + offset: 0, + inFlightAtStart: 1, + answeredBefore: 0, + }); + expect(restOffsets(e)).toEqual([100, 200, 300, 400, 500, 600, 700, 800]); + // ...all asked for AFTER page one answered and BEFORE any of them answered: one wave. + for (const one of e.asked.slice(1)) expect(one.answeredBefore).toBe(1); + expect(e.maxInFlight()).toBe(8); + }); + + test("the auth-provider read behind authProviderOf finishes before the deadline too", async () => { + const e = engine({ claimed: GITHUB_API }); + + expect( + await underDeadline((broker) => broker.authProviderOf(TOOLKIT)), + ).toBe("github"); + expect(e.asked.length).toBe(9); + // The catalogue read carries no person: it is the same listing for everybody. + for (const one of e.asked) expect(one.userId).toBeNull(); + expect(restOffsets(e)).toEqual([100, 200, 300, 400, 500, 600, 700, 800]); + expect(e.maxInFlight()).toBe(8); + }); + + test("Disconnect on GithubApi — the action listing, then the connection lifecycle — fits the deadline", async () => { + const e = engine({ claimed: GITHUB_API }); + + expect( + await underDeadline((broker) => + broker.revoke({ account, toolkit: TOOLKIT }), + ), + ).toBe(true); + expect(e.deletes()).toBe(1); + }); + + test("a bigger toolkit is still fetched at most eight pages at a time", async () => { + // 2,000 actions: 20 pages after the first, so the bound — not the toolkit — decides the peak. + const e = engine({ claimed: 2000, latency: { small: 5, large: 20 } }); + + expect((await grants()).length).toBe(2000); + expect(e.asked.length).toBe(21); + expect(e.maxInFlight()).toBe(8); + }); +}); + +describe("fetching a toolkit ahead does not weaken the incompleteness check", () => { + test("a toolkit that claims 818 and serves 750 is refused, on the grants screen and on Disconnect", async () => { + const e = engine({ claimed: GITHUB_API, served: 750 }); + + await expect(grants()).rejects.toThrow( + /listed 750 of 818 actions for GithubApi, so what this account holds there could not be read in full/, + ); + await expect( + underDeadline((broker) => broker.revoke({ account, toolkit: TOOLKIT })), + ).rejects.toThrow( + /listed 750 of 818 actions for GithubApi, so the auth provider/, + ); + expect(e.deletes()).toBe(0); + }); + + test("a short page in the MIDDLE ends the listing, and the pages fetched past it are not counted", async () => { + // The page at offset 400 holds 50 rows; the four pages fetched ahead past it hold nothing. + engine({ claimed: GITHUB_API, served: 450 }); + + await expect(grants()).rejects.toThrow(/listed 450 of 818 actions/); + }); + + test("a total that UNDER-reports is read on past, sequentially, to the real end", async () => { + // Claims 300, serves 818: the prefetch covers offsets 100…300, all full, so the listing must + // go on asking — a short page ends it, never the total. + const e = engine({ + claimed: 300, + served: GITHUB_API, + latency: { small: 5, large: 20 }, + }); + + expect((await grants()).length).toBe(GITHUB_API); + expect(e.asked.map((one) => one.offset).sort((a, b) => a - b)).toEqual([ + 0, 100, 200, 300, 400, 500, 600, 700, 800, + ]); + }); +}); From e5f0db1e82bcffc1a0f4039a39eeb6a034f7007c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 20:06:59 +0200 Subject: [PATCH 227/232] test(types): take main's #790 debt into the baseline, deliberately CI failed on `server/tests types (baselined)` while the same command was green locally. The cause is not this branch: GitHub runs a pull_request workflow against the branch MERGED WITH CURRENT MAIN, and main had moved on by one commit -- 8c9ad4c8, "Public-web research by role: Exa and Firecrawl beside Parallel (#790)". That commit adds server/tests/web-research.test.ts, a file this branch had never seen, and adds seven lines to copilot.test.ts above four already-baselined errors. So CI saw four familiar errors seven lines lower than recorded, plus three in a file the baseline does not mention. Locally, against a tree one commit behind main, every line still matched. main carries no typecheck-baseline.txt at all -- the gate is this branch's own, added by this review cycle -- so main is green by not having the check rather than by having no debt. New code landing on main therefore arrives here as unrecorded debt the first time the two meet. This merges main and records that debt, which is the deliberate decision the baseline's header reserves `--update` for. Done by hand instead, so nothing of this branch's own could be silenced along with it: - copilot.test.ts 1201 -> 1208, 1229 -> 1236, 1287 -> 1294, 1331 -> 1338 (same four errors, same messages, moved by main's seven lines) - web-research.test.ts 281, 289, 290 added (TS2339 'get' on never) 264 -> 267 entries. No entry was dropped and no message was altered. The three new entries are main's own: web-research.test.ts is main's file, this branch has never touched the research code, and the errors sit on main's own `headers?.get(...)` helper. typecheck 0, typecheck:tests clean at 267, lint 0, format:check 0, test:ci 8,048 pass across 8,124 tests, test:mutations 13/13. The one reported failure, enterprise-controls' idle-sweep test, came with two "too many clients" lines and passes 20/20 alone -- the same load-flaky file seen three times today. Co-Authored-By: Claude Opus 5 (1M context) --- server/tests/typecheck-baseline.txt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/server/tests/typecheck-baseline.txt b/server/tests/typecheck-baseline.txt index a1d956d45..15e078c72 100644 --- a/server/tests/typecheck-baseline.txt +++ b/server/tests/typecheck-baseline.txt @@ -104,10 +104,10 @@ server/tests/computer-policy.test.ts:662: error TS2345: Argument of type '{ tool server/tests/computer-provider.test.ts:418: error TS2345: Argument of type '{ provider: "sandbox"; namespace: string; idleAfterMs: number; templateFile: string; }' is not assignable to parameter of type 'ComputerConfig'. server/tests/copilot.test.ts:1090: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. server/tests/copilot.test.ts:1128: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. -server/tests/copilot.test.ts:1201: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. -server/tests/copilot.test.ts:1229: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. -server/tests/copilot.test.ts:1287: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. -server/tests/copilot.test.ts:1331: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. +server/tests/copilot.test.ts:1208: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1236: error TS2339: Property 'prompt' does not exist on type 'BuiltInAgentConfiguration'. +server/tests/copilot.test.ts:1294: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. +server/tests/copilot.test.ts:1338: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. server/tests/copilot.test.ts:154: error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FixtureFileResponse | ResponseFactory'. server/tests/copilot.test.ts:158: error TS2739: Type '{ id: string; name: string; type: "built_in"; systemPrompt: string; }[]' is missing the following properties from type 'Promise': then, catch, finally, [Symbol.toStringTag] server/tests/copilot.test.ts:371: error TS2322: Type '{ id: string; name: string; type: "remote_ag_ui"; endpoint: string; }' is not assignable to type 'RegisteredAgent'. @@ -139,8 +139,8 @@ server/tests/encrypt-sso-config.test.ts:139: error TS2554: Expected 0 arguments, server/tests/encrypt-sso-config.test.ts:74: error TS2554: Expected 0 arguments, but got 1. server/tests/encrypt-sso-config.test.ts:85: error TS2554: Expected 0 arguments, but got 1. server/tests/enterprise-controls.integration.test.ts:144: error TS2769: No overload matches this call. -server/tests/enterprise-controls.integration.test.ts:148: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. server/tests/enterprise-controls.integration.test.ts:147: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. +server/tests/enterprise-controls.integration.test.ts:148: error TS2345: Argument of type '(context: Parameters>[1]>[0], next: () => Promise) => Promise<...>' is not assignable to parameter of type 'MiddlewareHandler<{ Variables: AppVariables; }>'. server/tests/google-drive-rest.test.ts:108: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. server/tests/google-drive-rest.test.ts:122: error TS2345: Argument of type '{ provenance: string; url: string; }' is not assignable to parameter of type '{ provenance: string; url: string; authScheme: string | null; accountMode?: string | null | undefined; }'. server/tests/group-conversations.integration.test.ts:361: error TS2339: Property 'serverId' does not exist on type 'object'. @@ -275,3 +275,6 @@ server/tests/voice-session-routes.test.ts:162: error TS2739: Type 'Response & Ty server/tests/voice-session-store.integration.test.ts:123: error TS2345: Argument of type '`page-${string}`' is not assignable to parameter of type '`${string}-${string}-${string}-${string}-${string}`'. server/tests/voice-sessions.test.ts:43: error TS2741: Property 'preconnect' is missing in type '(_url: URL | RequestInfo, init: RequestInit | BunFetchRequestInit | undefined) => Promise' but required in type 'typeof fetch'. server/tests/voice-sessions.test.ts:85: error TS2741: Property 'preconnect' is missing in type '() => Promise' but required in type 'typeof fetch'. +server/tests/web-research.test.ts:281: error TS2339: Property 'get' does not exist on type 'never'. +server/tests/web-research.test.ts:289: error TS2339: Property 'get' does not exist on type 'never'. +server/tests/web-research.test.ts:290: error TS2339: Property 'get' does not exist on type 'never'. From 9c441b7d461ce53194b2d53e63c0750ed74346f6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 14 Oct 2026 22:37:01 +0200 Subject: [PATCH 228/232] fix(plugins): withhold the whole-app Connect where the broker authorizes per action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both screens that draw `BrokeredAccountRow` drew it unconditionally, so on an Arcade deployment a disconnected app offered a Connect that could only ever fail: the adapter refuses an authorization carrying no action, and the press came back "Arcade grants per action, so an authorization needs the action it is for and none was given." Every time, with no state in which it could succeed, and with the thing the person actually wanted — the per-action grant list — sitting underneath on the settings screen and absent on the admin one. Found by pressing the button, not by any test. A test was in fact waiting on that button as a render signal on an Arcade fixture, which is how it survived four fix waves. `useBrokeredAccount` now takes `authorizesPerAction` and the row withholds Connect where it is true, drawing the same "value and nothing to press" layout the missing-key case already draws, for the reason that case already gives: a button that cannot work is worse than no button. Read off the declared capability and never off the vendor's name, so the row's offer and the route behind it cannot key on different facts. Disconnect is NOT withheld. Per-action authorizations still add up to one account at the vendor, `revoke` is a real act on it, and this row is the only place it is offered — withholding both would strand somebody holding a live connection with no way to end it. The replacement sentence names where the authorizing actually happens, and that is not the same place on both screens: settings draws the action list directly below, the admin setup page draws none, so `brokeredPerActionTrip` takes the destination rather than defaulting to the commoner case and pointing an administrator at a list that is not on their page. Co-Authored-By: Claude Opus 5 (1M context) --- .../plugins/brokered-account-row.tsx | 39 +++++++++++++++ app/src/lib/plugins/provider-vocabulary.ts | 26 ++++++++++ app/src/routes/_authed/admin/plugins/$key.tsx | 17 ++++++- .../settings/connected-accounts/$key.tsx | 6 ++- app/tests/brokered-account-row.test.tsx | 50 +++++++++++++++++++ .../connected-account-confirmation.test.tsx | 8 ++- app/tests/shared-connected-accounts.test.tsx | 1 + 7 files changed, 143 insertions(+), 4 deletions(-) diff --git a/app/src/components/plugins/brokered-account-row.tsx b/app/src/components/plugins/brokered-account-row.tsx index 43bc167a8..e1e634743 100644 --- a/app/src/components/plugins/brokered-account-row.tsx +++ b/app/src/components/plugins/brokered-account-row.tsx @@ -170,6 +170,22 @@ export type BrokeredAccount = { * below can do anything but fail. */ configured: boolean; + /** + * Whether the broker grants ONE ACTION AT A TIME, which is whether a whole-app Connect exists. + * + * READ OFF THE DECLARED CAPABILITY AND NEVER OFF THE VENDOR'S NAME — the same rule, and the same + * server-computed field, the grant list is drawn on. `provider === "arcade"` would key the button + * on a different fact from the route behind it, and the two must not be able to disagree. + * + * THE ROW WITHHOLDS `Connect` WHERE THIS IS TRUE, AND NOTHING ELSE. A per-action broker has no + * whole-app consent to mint — the adapter refuses an authorization with no action on it — so the + * button could only ever reach the vendor's refusal, which is the state this closes: somebody + * pressed it, was told "an authorization needs the action it is for", and had no way to tell from + * the screen that the thing they wanted was the list underneath. `Disconnect` is NOT withheld: + * per-action authorizations still add up to one account at the vendor, and ending it is a real + * act this row is the only offer of. + */ + authorizesPerAction: boolean; /** See {@link BrokeredAccountKind}. Derived here so no branch below re-asks. */ kind: BrokeredAccountKind; /** Leave for the vendor's consent screen. */ @@ -276,6 +292,8 @@ export function useBrokeredAccount(input: { * from and never how an app connects. Null where the app is not brokered at all. */ authScheme: string | null; + /** See {@link BrokeredAccount.authorizesPerAction}. The server's computed capability, passed in. */ + authorizesPerAction: boolean; /** Which screen the vendor's callback puts somebody down on. */ returnTo: "settings" | "admin"; /** @@ -289,6 +307,7 @@ export function useBrokeredAccount(input: { const queryClient = useQueryClient(); const { authScheme, + authorizesPerAction, brokered, checkable, configured, @@ -616,6 +635,9 @@ export function useBrokeredAccount(input: { * offered or withheld for an instant, not a sentence anybody is told. */ checkable, + /* Passed straight through: it is the server's answer about the app, and nothing pressed here + can make a broker start or stop granting per action. */ + authorizesPerAction, recheck: () => { report(null); recheck.mutate(serverId); @@ -998,6 +1020,23 @@ export function BrokeredAccountRow({ Disconnect + ) : account.authorizesPerAction ? ( + /* + * A value and nothing to press, the same layout the missing key draws above and for + * the same reason given there: a button that cannot work is worse than no button. + * + * NOT A DISABLED `Connect`. A greyed button says "not yet", and announces a step + * somebody is missing — but there is no later state in which a per-action broker + * grows a whole-app consent, so the step it names does not exist. What this person + * wants is the action list, and the sentence beside this row sends them to it. + * + * IT SAYS THE MECHANISM AND NOT A STATE. "Approved per action" is true before anybody + * has approved anything and stays true after all of it is approved; the account's own + * state is the branch above, which this one is reached only by failing. + */ + + Approved per action + ) : ( /* The arrow says this leaves OpenBot for the vendor's consent page. It does. */