From 64ef86a18ee9b852047f214212b7c9d453729fb8 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:36:28 -0300 Subject: [PATCH 001/101] feat(app): add link confirmation state --- app/src/lib/identity/link-state.test.ts | 60 +++++++++++++++++++++++++ app/src/lib/identity/link-state.ts | 45 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 app/src/lib/identity/link-state.test.ts create mode 100644 app/src/lib/identity/link-state.ts diff --git a/app/src/lib/identity/link-state.test.ts b/app/src/lib/identity/link-state.test.ts new file mode 100644 index 000000000..7e87fc64f --- /dev/null +++ b/app/src/lib/identity/link-state.test.ts @@ -0,0 +1,60 @@ +// app/src/lib/identity/link-state.test.ts +import { expect, test } from "bun:test"; +import { confirmLinkState } from "./link-state"; + +const idle = { status: "idle" as const }; +const token = "a".repeat(43); + +test("no or malformed token is invalid", () => { + expect( + confirmLinkState({ + token: undefined, + peek: { status: "pending" }, + confirm: idle, + }).kind, + ).toBe("invalid"); + expect( + confirmLinkState({ + token: "short", + peek: { status: "pending" }, + confirm: idle, + }).kind, + ).toBe("invalid"); +}); + +test("peek pending is loading; peek error is expired", () => { + expect( + confirmLinkState({ token, peek: { status: "pending" }, confirm: idle }) + .kind, + ).toBe("loading"); + expect( + confirmLinkState({ token, peek: { status: "error" }, confirm: idle }).kind, + ).toBe("expired"); +}); + +test("a live challenge is ready and names its provider", () => { + expect( + confirmLinkState({ + token, + peek: { status: "success", data: { title: "Slack" } }, + confirm: idle, + }), + ).toEqual({ kind: "ready", title: "Slack" }); +}); + +test("confirm states", () => { + const peek = { status: "success" as const, data: { title: "Slack" } }; + expect( + confirmLinkState({ token, peek, confirm: { status: "pending" } }).kind, + ).toBe("confirming"); + expect( + confirmLinkState({ token, peek, confirm: { status: "error" } }).kind, + ).toBe("failed"); + expect( + confirmLinkState({ + token, + peek, + confirm: { status: "success", hint: "do X" }, + }), + ).toEqual({ kind: "done", title: "Slack", hint: "do X" }); +}); diff --git a/app/src/lib/identity/link-state.ts b/app/src/lib/identity/link-state.ts new file mode 100644 index 000000000..f941241d0 --- /dev/null +++ b/app/src/lib/identity/link-state.ts @@ -0,0 +1,45 @@ +// app/src/lib/identity/link-state.ts +type Peek = { + status: "pending" | "error" | "success"; + data?: { title: string }; +}; +type Confirm = { + status: "idle" | "pending" | "error" | "success"; + hint?: string | null; +}; +export type ConfirmLinkState = { + kind: + | "invalid" + | "loading" + | "expired" + | "ready" + | "confirming" + | "failed" + | "done"; + title?: string; + hint?: string | null; +}; + +/** What the confirm page draws, from the token and its two requests. Pure, so it is tested alone. */ +export function confirmLinkState(input: { + token: string | undefined; + peek: Peek; + confirm: Confirm; +}): ConfirmLinkState { + if (!input.token || !/^[A-Za-z0-9_-]{43}$/.test(input.token)) + return { kind: "invalid" }; + if (input.peek.status === "pending") return { kind: "loading" }; + if (input.peek.status === "error" || !input.peek.data) + return { kind: "expired" }; + const title = input.peek.data.title; + switch (input.confirm.status) { + case "pending": + return { kind: "confirming" }; + case "error": + return { kind: "failed" }; + case "success": + return { kind: "done", title, hint: input.confirm.hint ?? null }; + default: + return { kind: "ready", title }; + } +} From 1ba525740622cb0c0ed385a77a4f47bcf3e4dd8b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:36:30 -0300 Subject: [PATCH 002/101] feat(server): add identity link types --- server/src/identity/types.ts | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 server/src/identity/types.ts diff --git a/server/src/identity/types.ts b/server/src/identity/types.ts new file mode 100644 index 000000000..043e78046 --- /dev/null +++ b/server/src/identity/types.ts @@ -0,0 +1,43 @@ +export type IdentityProvider = "slack" | "github"; +export type LinkMethod = "challenge" | "oauth"; +export type LinkStatus = "active" | "needs_reconnect"; + +/** Who somebody is at a provider. `subject` is the provider's stable id, never a display name. */ +export type Identity = { + provider: IdentityProvider; + realm: string; + subject: string; +}; + +export type IdentityLink = Identity & { + id: string; + userId: string; + handle: string | null; + verifiedBy: LinkMethod; + credentialId: string | null; + status: LinkStatus; + createdAt: Date; + updatedAt: Date; +}; + +/** No caller-controlled identifiers, tokens, or database parameters appear in these errors. */ +export class IdentityLinkError extends Error { + constructor(message = "Link challenge is invalid, expired, or unconfirmed.") { + super(message); + this.name = "IdentityLinkError"; + } +} + +export class IdentityConflictError extends Error { + constructor() { + super("This account is already linked to another OpenBot user."); + this.name = "IdentityConflictError"; + } +} + +export class IdentityInputError extends Error { + constructor(message = "Identity is not valid.") { + super(message); + this.name = "IdentityInputError"; + } +} From 6eb24f8d62dfae2b53a6b2ee9327321417aa08fb Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:36:39 -0300 Subject: [PATCH 003/101] docs: describe linked accounts --- docs/configuration.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index efca919c3..d722a12d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -485,6 +485,15 @@ SMS through Twilio honours Advanced Opt-Out: `STOP` (and Twilio's other opt-out number opted out, later messages show `opted_out` in the delivery history instead of being sent, and `START` resumes. Twilio sends the confirmation reply itself, so OpenBot does not. +### Linked accounts + +A person's accounts at outside providers — their Slack user, their GitHub account — are linked to +their OpenBot user in one place, `identity_links`, and listed under **Linked accounts** on +`/settings/connected-accounts`, where they can disconnect any of them. A link is proven by the +provider's own sign-in (GitHub) or by a two-sided challenge a person starts in a chat app and +confirms at `/settings/link` (Slack). Nobody can link an account on somebody else's behalf, and an +outside account links to one OpenBot user at most. + ## Text messages and push notifications A provider is switched on by setting its variables and is off while none of them are set. Setting From f2304ca9341738be612660024b9b216592baf250 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:36:40 -0300 Subject: [PATCH 004/101] feat(app): add linked account queries --- app/src/lib/identity/queries.ts | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 app/src/lib/identity/queries.ts diff --git a/app/src/lib/identity/queries.ts b/app/src/lib/identity/queries.ts new file mode 100644 index 000000000..903897081 --- /dev/null +++ b/app/src/lib/identity/queries.ts @@ -0,0 +1,49 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +export type LinkedAccount = { + id: string; + provider: "slack" | "github"; + title: string; + handle: string | null; + status: "active" | "needs_reconnect"; + createdAt: string; +}; + +/** What the confirm page learns about a link challenge before the person agrees to it. */ +export type LinkChallenge = { + provider: LinkedAccount["provider"]; + title: string; + handle: string | null; +}; + +export const identityKeys = { + all: ["identity"] as const, + links: () => [...identityKeys.all, "links"] as const, + challenge: (token: string) => + [...identityKeys.all, "challenge", token] as const, +}; + +export function linkedAccountsQueryOptions() { + return queryOptions({ + queryKey: identityKeys.links(), + queryFn: async (): Promise => + client("/api/identity/links", "links", { + fallback: "Could not load your linked accounts", + }), + }); +} + +/** A POST because the token is a secret and does not belong in a URL the server logs. */ +export function challengeQueryOptions(token: string) { + return queryOptions({ + queryKey: identityKeys.challenge(token), + queryFn: async (): Promise => + client("/api/identity/challenges/peek", "challenge", { + method: "POST", + body: { token }, + fallback: "This link expired or was already used", + }), + retry: false, + }); +} From 59d521aedbb0aab914b871be873f56d91e13d164 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:36:41 -0300 Subject: [PATCH 005/101] feat(server): add identity link tables --- server/drizzle.config.ts | 1 + server/drizzle/0052_identity_links.sql | 30 + server/drizzle/meta/0052_snapshot.json | 9226 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/identity.ts | 78 + server/src/db/schema/index.ts | 1 + .../tests/identity-schema.integration.test.ts | 53 + 7 files changed, 9396 insertions(+) create mode 100644 server/drizzle/0052_identity_links.sql create mode 100644 server/drizzle/meta/0052_snapshot.json create mode 100644 server/src/db/schema/identity.ts create mode 100644 server/tests/identity-schema.integration.test.ts diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 7bafc3e33..de16a9031 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ "./src/db/schema/lifecycle.ts", "./src/db/schema/passwords.ts", "./src/db/schema/team-bots.ts", + "./src/db/schema/identity.ts", ], out: "./drizzle", dbCredentials: { diff --git a/server/drizzle/0052_identity_links.sql b/server/drizzle/0052_identity_links.sql new file mode 100644 index 000000000..a08c73814 --- /dev/null +++ b/server/drizzle/0052_identity_links.sql @@ -0,0 +1,30 @@ +CREATE TABLE "identity_link_challenges" ( + "token_hash" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "realm" text NOT NULL, + "subject" text NOT NULL, + "handle" text, + "confirmed_user_id" text, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "identity_links" ( + "id" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "realm" text NOT NULL, + "subject" text NOT NULL, + "user_id" text NOT NULL, + "handle" text, + "verified_by" text NOT NULL, + "credential_id" uuid, + "status" text DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "identity_links_verified_by_check" CHECK ("identity_links"."verified_by" IN ('challenge', 'oauth')), + CONSTRAINT "identity_links_status_check" CHECK ("identity_links"."status" IN ('active', 'needs_reconnect')) +); +--> statement-breakpoint +ALTER TABLE "identity_links" ADD CONSTRAINT "identity_links_credential_id_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."credentials"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "identity_link_challenges_expiry_idx" ON "identity_link_challenges" USING btree ("expires_at");--> statement-breakpoint +CREATE UNIQUE INDEX "identity_links_identity_idx" ON "identity_links" USING btree ("provider","realm","subject");--> statement-breakpoint +CREATE UNIQUE INDEX "identity_links_user_realm_idx" ON "identity_links" USING btree ("user_id","provider","realm"); \ No newline at end of file diff --git a/server/drizzle/meta/0052_snapshot.json b/server/drizzle/meta/0052_snapshot.json new file mode 100644 index 000000000..c6428b47b --- /dev/null +++ b/server/drizzle/meta/0052_snapshot.json @@ -0,0 +1,9226 @@ +{ + "id": "d5ad8938-e2e1-4eea-8e82-9a84125edd1d", + "prevId": "b4bb328d-1308-403f-a2ac-f8a291aaabc5", + "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.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 + }, + "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": {}, + "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.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 + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmed_user_id": { + "name": "confirmed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + } + }, + "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": {} + } + }, + "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 5d6a53b9d..48c0e6269 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1790939356628, "tag": "0051_routine_enabled_at", "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1791470171980, + "tag": "0052_identity_links", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/identity.ts b/server/src/db/schema/identity.ts new file mode 100644 index 000000000..0ae109b8f --- /dev/null +++ b/server/src/db/schema/identity.ts @@ -0,0 +1,78 @@ +// server/src/db/schema/identity.ts +/** + * Accounts at outside providers, linked to OpenBot users. + * + * Provider-neutral on purpose: Slack senders and GitHub users are the same question ("who is this + * here?") asked in two places. See docs/superpowers/specs/2026-10-08-github-app-integration-design.md. + */ +import { sql } from "drizzle-orm"; +import { + check, + index, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { credentials } from "./core"; + +const at = (name: string) => + timestamp(name, { withTimezone: true }).notNull().defaultNow(); + +/** `user_id` is deliberately not a foreign key: a deleted user's link must not become a guest. */ +export const identityLinks = pgTable( + "identity_links", + { + id: text("id").primaryKey(), + provider: text("provider").notNull(), + realm: text("realm").notNull(), + subject: text("subject").notNull(), + userId: text("user_id").notNull(), + handle: text("handle"), + verifiedBy: text("verified_by").$type<"challenge" | "oauth">().notNull(), + credentialId: uuid("credential_id").references(() => credentials.id), + status: text("status") + .$type<"active" | "needs_reconnect">() + .notNull() + .default("active"), + createdAt: at("created_at"), + updatedAt: at("updated_at"), + }, + (table) => [ + uniqueIndex("identity_links_identity_idx").on( + table.provider, + table.realm, + table.subject, + ), + // Also serves (user_id, provider) lookups as its prefix. + uniqueIndex("identity_links_user_realm_idx").on( + table.userId, + table.provider, + table.realm, + ), + check( + "identity_links_verified_by_check", + sql`${table.verifiedBy} IN ('challenge', 'oauth')`, + ), + check( + "identity_links_status_check", + sql`${table.status} IN ('active', 'needs_reconnect')`, + ), + ], +); + +/** Neither possession of the token nor the signed-in confirmation alone establishes a link. */ +export const identityLinkChallenges = pgTable( + "identity_link_challenges", + { + tokenHash: text("token_hash").primaryKey(), + provider: text("provider").notNull(), + realm: text("realm").notNull(), + subject: text("subject").notNull(), + handle: text("handle"), + confirmedUserId: text("confirmed_user_id"), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (table) => [index("identity_link_challenges_expiry_idx").on(table.expiresAt)], +); diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index ee01bed4a..709639299 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -9,6 +9,7 @@ export * from "./coworker"; export * from "./delivery"; export * from "./demonstrations"; export * from "./group"; +export * from "./identity"; export * from "./learning"; export * from "./lifecycle"; export * from "./memory"; diff --git a/server/tests/identity-schema.integration.test.ts b/server/tests/identity-schema.integration.test.ts new file mode 100644 index 000000000..6235303fd --- /dev/null +++ b/server/tests/identity-schema.integration.test.ts @@ -0,0 +1,53 @@ +// server/tests/identity-schema.integration.test.ts +import { afterAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { identityLinks } from "../src/db/schema"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const realm = `test-${randomUUID()}`; + +afterAll(async () => { + await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); + await database.$client.close(); +}); + +const row = (subject: string, userId: string) => ({ + id: randomUUID(), + provider: "slack", + realm, + subject, + userId, + verifiedBy: "challenge" as const, +}); + +test("an outside account links to one user", async () => { + await database.insert(identityLinks).values(row("U1", "user-a")); + await expect( + database.insert(identityLinks).values(row("U1", "user-b")).execute(), + ).rejects.toThrow(); +}); + +test("a user has one account per provider and realm", async () => { + await database.insert(identityLinks).values(row("U2", "user-c")); + await expect( + database.insert(identityLinks).values(row("U3", "user-c")).execute(), + ).rejects.toThrow(); +}); + +test("verified_by and status are constrained", async () => { + await expect( + database + .insert(identityLinks) + .values({ ...row("U4", "user-d"), verifiedBy: "guess" as never }) + .execute(), + ).rejects.toThrow(); + await expect( + database + .insert(identityLinks) + .values({ ...row("U5", "user-e"), status: "gone" as never }) + .execute(), + ).rejects.toThrow(); +}); From b249fe44f64eb1746a8ebcfdfa0dedd22bcbef6c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:38:46 -0300 Subject: [PATCH 006/101] feat(app): add linked account mutations --- app/src/lib/identity/mutations.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 app/src/lib/identity/mutations.ts diff --git a/app/src/lib/identity/mutations.ts b/app/src/lib/identity/mutations.ts new file mode 100644 index 000000000..daa689999 --- /dev/null +++ b/app/src/lib/identity/mutations.ts @@ -0,0 +1,31 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { identityKeys } from "./queries"; + +export function unlinkMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (id: string): Promise => { + await client(`/api/identity/links/${encodeURIComponent(id)}`, { + method: "DELETE", + fallback: "The account could not be disconnected", + }); + }, + onSettled: () => + queryClient.invalidateQueries({ queryKey: identityKeys.links() }), + }); +} + +export function confirmChallengeMutationOptions() { + return mutationOptions({ + mutationFn: (token: string): Promise<{ hint: string | null }> => + client<{ hint: string | null }>( + "/api/identity/challenges/confirm", + "confirmation", + { + method: "POST", + body: { token }, + fallback: "This link could not be confirmed", + }, + ), + }); +} From 271005fcc45ece0a40bf0232d930af6736d95132 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:38:46 -0300 Subject: [PATCH 007/101] feat(server): add identity provider registry --- server/src/identity/providers.ts | 68 ++++++++++++++++++++ server/tests/identity-providers.test.ts | 82 +++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 server/src/identity/providers.ts create mode 100644 server/tests/identity-providers.test.ts diff --git a/server/src/identity/providers.ts b/server/src/identity/providers.ts new file mode 100644 index 000000000..628a5c1ee --- /dev/null +++ b/server/src/identity/providers.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import { + type Identity, + IdentityInputError, + type IdentityProvider, + type LinkMethod, +} from "./types"; + +type ProviderSpec = { + title: string; + /** The proofs this provider's links may be made with. Anything else is refused by the store. */ + methods: readonly LinkMethod[]; + /** For challenge providers: what the person does at the provider to finish. */ + completionHint?: (token: string) => string; +}; + +export const PROVIDERS: Readonly> = + Object.freeze({ + slack: { + title: "Slack", + methods: ["challenge"], + completionHint: (token) => + `From the Slack account that asked for this link, mention the same bot with: /link ${token}`, + }, + github: { title: "GitHub", methods: ["oauth"] }, + }); + +export const GITHUB_DOTCOM_REALM = "github.com"; + +export function isIdentityProvider(value: unknown): value is IdentityProvider { + return typeof value === "string" && Object.hasOwn(PROVIDERS, value); +} + +export function acceptsMethod(provider: IdentityProvider, method: LinkMethod) { + return PROVIDERS[provider].methods.includes(method); +} + +/** + * The Slack branch matched a sender on connection, installation and workspace. The realm keeps + * exactly that, each part escaped so a colon inside one part cannot imitate the separator. + */ +export function slackRealm(parts: { + connectionId: string; + installationId: string; + workspaceId: string; +}): string { + const values = [parts.connectionId, parts.installationId, parts.workspaceId]; + if (values.some((value) => !value.trim())) + throw new IdentityInputError("Slack identity is incomplete."); + return values.map(encodeURIComponent).join(":"); +} + +const identitySchema = z.object({ + provider: z.string().refine(isIdentityProvider), + realm: z.string().min(1).max(512), + subject: z.string().min(1).max(256), +}); + +/** Explicit fields only, so extra runtime properties never reach the persistence contract. */ +export function parseIdentity(value: unknown): Identity { + const parsed = identitySchema.safeParse(value); + if (!parsed.success) throw new IdentityInputError(); + return { + provider: parsed.data.provider as IdentityProvider, + realm: parsed.data.realm, + subject: parsed.data.subject, + }; +} diff --git a/server/tests/identity-providers.test.ts b/server/tests/identity-providers.test.ts new file mode 100644 index 000000000..8362d2efc --- /dev/null +++ b/server/tests/identity-providers.test.ts @@ -0,0 +1,82 @@ +// server/tests/identity-providers.test.ts +import { expect, test } from "bun:test"; +import { + acceptsMethod, + GITHUB_DOTCOM_REALM, + isIdentityProvider, + PROVIDERS, + parseIdentity, + slackRealm, +} from "../src/identity/providers"; +import { IdentityInputError } from "../src/identity/types"; + +test("slackRealm keeps the three parts the Slack branch matched on", () => { + expect( + slackRealm({ + connectionId: "openbot", + installationId: "I1", + workspaceId: "T1", + }), + ).toBe("openbot:I1:T1"); +}); + +test("slackRealm cannot be confused by a colon", () => { + const a = slackRealm({ + connectionId: "a:b", + installationId: "c", + workspaceId: "d", + }); + const b = slackRealm({ + connectionId: "a", + installationId: "b:c", + workspaceId: "d", + }); + expect(a).not.toBe(b); +}); + +test("slackRealm refuses an empty part", () => { + expect(() => + slackRealm({ connectionId: "", installationId: "I1", workspaceId: "T1" }), + ).toThrow(IdentityInputError); +}); + +test("GitHub's realm is its host", () => { + expect(GITHUB_DOTCOM_REALM).toBe("github.com"); +}); + +test("each provider declares the proofs it accepts", () => { + expect(acceptsMethod("slack", "challenge")).toBe(true); + expect(acceptsMethod("slack", "oauth")).toBe(false); + expect(acceptsMethod("github", "oauth")).toBe(true); + expect(acceptsMethod("github", "challenge")).toBe(false); +}); + +test("Slack tells the person how to finish from Slack", () => { + expect(PROVIDERS.slack.completionHint?.("tok")).toContain("/link tok"); +}); + +test("isIdentityProvider", () => { + expect(isIdentityProvider("slack")).toBe(true); + expect(isIdentityProvider("myspace")).toBe(false); + expect(isIdentityProvider(1)).toBe(false); +}); + +test("parseIdentity strips extra properties and bounds sizes", () => { + expect( + parseIdentity({ + provider: "github", + realm: "github.com", + subject: "42", + userId: "x", + }), + ).toEqual({ provider: "github", realm: "github.com", subject: "42" }); + expect(() => + parseIdentity({ provider: "nope", realm: "r", subject: "s" }), + ).toThrow(IdentityInputError); + expect(() => + parseIdentity({ provider: "slack", realm: "r", subject: "s".repeat(257) }), + ).toThrow(IdentityInputError); + expect(() => + parseIdentity({ provider: "slack", realm: "", subject: "s" }), + ).toThrow(IdentityInputError); +}); From 3b27716290c22a74f89757d961f7fac2dfd0ccb9 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:40:16 -0300 Subject: [PATCH 008/101] feat(server): add identity link store --- server/src/identity/store.ts | 226 ++++++++++++++++++ .../tests/identity-store.integration.test.ts | 168 +++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 server/src/identity/store.ts create mode 100644 server/tests/identity-store.integration.test.ts diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts new file mode 100644 index 000000000..a34193f61 --- /dev/null +++ b/server/src/identity/store.ts @@ -0,0 +1,226 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { CredentialRefusedError, type CredentialStore } from "../credentials"; +import type { Database } from "../db/client"; +import { identityLinks } from "../db/schema"; +import { acceptsMethod, parseIdentity } from "./providers"; +import { + type Identity, + IdentityConflictError, + IdentityInputError, + type IdentityLink, + type IdentityProvider, + type LinkMethod, + type LinkStatus, +} from "./types"; + +type Transaction = Parameters[0]>[0]; +type Revoke = Pick; + +const identityMatches = (identity: Identity) => + and( + eq(identityLinks.provider, identity.provider), + eq(identityLinks.realm, identity.realm), + eq(identityLinks.subject, identity.subject), + ); + +function toLink(row: typeof identityLinks.$inferSelect): IdentityLink { + return { + id: row.id, + provider: row.provider as IdentityProvider, + realm: row.realm, + subject: row.subject, + userId: row.userId, + handle: row.handle, + verifiedBy: row.verifiedBy, + credentialId: row.credentialId, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** Postgres unique_violation. Bun's driver puts the SQLSTATE in `errno`; drizzle wraps it as `cause`. */ +function isUniqueViolation(error: unknown) { + const sqlState = (value: unknown) => + (value as { errno?: unknown } | null | undefined)?.errno; + return ( + sqlState(error) === "23505" || + sqlState((error as { cause?: unknown } | null | undefined)?.cause) === + "23505" + ); +} + +async function revokeQuietly( + credentials: Revoke, + id: string | null, + executor: Transaction, +) { + if (!id) return; + try { + await credentials.revoke(id, executor); + } catch (error) { + // Already revoked is the outcome we wanted. + if (!(error instanceof CredentialRefusedError)) throw error; + } +} + +/** + * Link `identity` to `userId` inside `transaction`. + * + * The identity row is locked first. Linked to somebody else: refused. Linked to this person: updated + * in place. Otherwise this person's older link in the same realm, if any, is removed with its + * credential, and the new link inserted. A racing insert of the same identity surfaces as a unique + * violation, which is the same refusal. + */ +export async function writeLink( + transaction: Transaction, + credentials: Revoke, + identity: Identity, + userId: string, + proof: { + method: LinkMethod; + handle?: string | null; + credentialId?: string | null; + }, +): Promise { + const [existing] = await transaction + .select() + .from(identityLinks) + .where(identityMatches(identity)) + .for("update"); + if (existing && existing.userId !== userId) throw new IdentityConflictError(); + const now = new Date(); + if (existing) { + if (existing.credentialId && existing.credentialId !== proof.credentialId) + await revokeQuietly(credentials, existing.credentialId, transaction); + const [updated] = await transaction + .update(identityLinks) + .set({ + handle: proof.handle ?? existing.handle, + verifiedBy: proof.method, + credentialId: proof.credentialId ?? null, + status: "active", + updatedAt: now, + }) + .where(eq(identityLinks.id, existing.id)) + .returning(); + return toLink(updated as typeof identityLinks.$inferSelect); + } + const [previous] = await transaction + .select() + .from(identityLinks) + .where( + and( + eq(identityLinks.userId, userId), + eq(identityLinks.provider, identity.provider), + eq(identityLinks.realm, identity.realm), + ), + ) + .for("update"); + if (previous) { + await transaction + .delete(identityLinks) + .where(eq(identityLinks.id, previous.id)); + await revokeQuietly(credentials, previous.credentialId, transaction); + } + try { + const [inserted] = await transaction + .insert(identityLinks) + .values({ + id: randomUUID(), + provider: identity.provider, + realm: identity.realm, + subject: identity.subject, + userId, + handle: proof.handle ?? null, + verifiedBy: proof.method, + credentialId: proof.credentialId ?? null, + status: "active", + createdAt: now, + updatedAt: now, + }) + .returning(); + return toLink(inserted as typeof identityLinks.$inferSelect); + } catch (error) { + if (isUniqueViolation(error)) throw new IdentityConflictError(); + throw error; + } +} + +export function createIdentityStore(database: Database, credentials: Revoke) { + return { + async linkedUser( + value: Identity, + ): Promise<{ userId: string; status: LinkStatus } | null> { + const identity = parseIdentity(value); + const [link] = await database + .select({ userId: identityLinks.userId, status: identityLinks.status }) + .from(identityLinks) + .where(identityMatches(identity)); + return link ?? null; + }, + + async identitiesFor( + userId: string, + provider?: IdentityProvider, + ): Promise { + const rows = await database + .select() + .from(identityLinks) + .where( + provider + ? and( + eq(identityLinks.userId, userId), + eq(identityLinks.provider, provider), + ) + : eq(identityLinks.userId, userId), + ) + .orderBy(identityLinks.provider, identityLinks.createdAt); + return rows.map(toLink); + }, + + async linkVerified( + value: Identity, + userId: string, + proof: { + method: LinkMethod; + handle?: string | null; + credentialId?: string | null; + }, + ): Promise { + const identity = parseIdentity(value); + if (!userId.trim()) throw new IdentityInputError(); + if (!acceptsMethod(identity.provider, proof.method)) + throw new IdentityInputError( + "This provider does not accept that kind of proof.", + ); + return database.transaction((transaction) => + writeLink(transaction, credentials, identity, userId, proof), + ); + }, + + async markNeedsReconnect(linkId: string): Promise { + await database + .update(identityLinks) + .set({ status: "needs_reconnect", updatedAt: new Date() }) + .where(eq(identityLinks.id, linkId)); + }, + + async unlink(userId: string, linkId: string): Promise { + return database.transaction(async (transaction) => { + const [removed] = await transaction + .delete(identityLinks) + .where( + and(eq(identityLinks.id, linkId), eq(identityLinks.userId, userId)), + ) + .returning({ credentialId: identityLinks.credentialId }); + if (!removed) return false; + await revokeQuietly(credentials, removed.credentialId, transaction); + return true; + }); + }, + }; +} + +export type IdentityStore = ReturnType; diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts new file mode 100644 index 000000000..a402369fa --- /dev/null +++ b/server/tests/identity-store.integration.test.ts @@ -0,0 +1,168 @@ +import { afterAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { credentials, identityLinks } from "../src/db/schema"; +import { createIdentityStore } from "../src/identity/store"; +import { + type Identity, + IdentityConflictError, + IdentityInputError, +} from "../src/identity/types"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const vault = createCredentialStore(database); +const store = createIdentityStore(database, vault); +const realm = `test-${randomUUID()}`; +const createdCredentials: string[] = []; + +afterAll(async () => { + await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); + if (createdCredentials.length) + await database + .delete(credentials) + .where(inArray(credentials.id, createdCredentials)); + await database.$client.close(); +}); + +const github = (subject = randomUUID()): Identity => ({ + provider: "github", + realm, + subject, +}); +const user = () => `user-${randomUUID()}`; + +async function credential() { + const { id } = await vault.create({ + kind: "connector", + provider: "github-user-token", + keyId: randomUUID(), + metadata: {}, + encryptedValue: "not-a-real-ciphertext", + }); + createdCredentials.push(id); + return id; +} + +test("an unlinked identity resolves to null", async () => { + expect(await store.linkedUser(github())).toBeNull(); +}); + +test("linkVerified links, and linkedUser and identitiesFor read it back", async () => { + const person = user(); + const identity = github(); + const link = await store.linkVerified(identity, person, { + method: "oauth", + handle: "dana", + }); + expect(link.userId).toBe(person); + expect(link.status).toBe("active"); + expect(await store.linkedUser(identity)).toEqual({ + userId: person, + status: "active", + }); + const mine = await store.identitiesFor(person, "github"); + expect(mine.map((row) => row.subject)).toEqual([identity.subject]); + expect(await store.identitiesFor(person, "slack")).toEqual([]); +}); + +test("a method the provider does not declare is refused", async () => { + await expect( + store.linkVerified(github(), user(), { method: "challenge" }), + ).rejects.toThrow(IdentityInputError); +}); + +test("an identity linked to somebody else is refused", async () => { + const identity = github(); + await store.linkVerified(identity, user(), { method: "oauth" }); + await expect( + store.linkVerified(identity, user(), { method: "oauth" }), + ).rejects.toThrow(IdentityConflictError); +}); + +test("concurrent links of one identity by two people: exactly one wins", async () => { + const identity = github(); + const results = await Promise.allSettled([ + store.linkVerified(identity, user(), { method: "oauth" }), + store.linkVerified(identity, user(), { method: "oauth" }), + ]); + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + const rejected = results.find((r) => r.status === "rejected"); + expect((rejected as PromiseRejectedResult).reason).toBeInstanceOf( + IdentityConflictError, + ); +}); + +test("relinking the same identity updates handle and credential, revoking the old one", async () => { + const person = user(); + const identity = github(); + const first = await credential(); + await store.linkVerified(identity, person, { + method: "oauth", + handle: "old", + credentialId: first, + }); + const second = await credential(); + const link = await store.linkVerified(identity, person, { + method: "oauth", + handle: "new", + credentialId: second, + }); + expect(link.handle).toBe("new"); + expect(link.credentialId).toBe(second); + expect(await vault.isLive(first)).toBe(false); + expect(await vault.isLive(second)).toBe(true); +}); + +test("linkVerified replaces a person's older link in the same realm and revokes its credential", async () => { + const person = user(); + const old = github(); + const oldCredential = await credential(); + await store.linkVerified(old, person, { + method: "oauth", + credentialId: oldCredential, + }); + const replacement = github(); + await store.linkVerified(replacement, person, { method: "oauth" }); + expect(await store.linkedUser(old)).toBeNull(); + expect((await store.linkedUser(replacement))?.userId).toBe(person); + expect(await vault.isLive(oldCredential)).toBe(false); +}); + +test("markNeedsReconnect is visible to linkedUser; relinking makes it active again", async () => { + const person = user(); + const identity = github(); + const link = await store.linkVerified(identity, person, { method: "oauth" }); + await store.markNeedsReconnect(link.id); + expect((await store.linkedUser(identity))?.status).toBe("needs_reconnect"); + await store.linkVerified(identity, person, { method: "oauth" }); + expect((await store.linkedUser(identity))?.status).toBe("active"); +}); + +test("unlink removes only the asker's own link and revokes its credential", async () => { + const person = user(); + const identity = github(); + const token = await credential(); + const link = await store.linkVerified(identity, person, { + method: "oauth", + credentialId: token, + }); + expect(await store.unlink(user(), link.id)).toBe(false); + expect(await store.linkedUser(identity)).not.toBeNull(); + expect(await store.unlink(person, link.id)).toBe(true); + expect(await store.linkedUser(identity)).toBeNull(); + expect(await vault.isLive(token)).toBe(false); + expect(await store.unlink(person, link.id)).toBe(false); +}); + +test("a link outlives its user: nothing cascades from users", async () => { + const identity = github(); + await store.linkVerified(identity, "user-that-never-existed", { + method: "oauth", + }); + expect((await store.linkedUser(identity))?.userId).toBe( + "user-that-never-existed", + ); +}); From 569a4e4a1cb55d7236a21521cfc856a8756e9d98 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:40:42 -0300 Subject: [PATCH 009/101] feat(app): confirm chat-started account links --- app/src/routeTree.gen.ts | 21 ++++++ app/src/routes/_authed/settings/link.tsx | 83 ++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 app/src/routes/_authed/settings/link.tsx diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 6b5eb9d39..3490f6ffc 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -36,6 +36,7 @@ import { Route as AuthedAdminPeopleRouteImport } from './routes/_authed/admin/pe import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground' import { Route as AuthedAdminSkillsRouteImport } from './routes/_authed/admin/skills' import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index' +import { Route as AuthedSettingsLinkRouteImport } from './routes/_authed/settings/link' import { Route as AuthedSettingsPasswordsRouteImport } from './routes/_authed/settings/passwords' import { Route as AuthedSignInRequestIdRouteImport } from './routes/_authed/sign-in/$requestId' import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index' @@ -192,6 +193,11 @@ const AuthedSettingsIndexRoute = AuthedSettingsIndexRouteImport.update({ path: '/', getParentRoute: () => AuthedSettingsRouteRoute, } as any) +const AuthedSettingsLinkRoute = AuthedSettingsLinkRouteImport.update({ + id: '/link', + path: '/link', + getParentRoute: () => AuthedSettingsRouteRoute, +} as any) const AuthedSettingsPasswordsRoute = AuthedSettingsPasswordsRouteImport.update({ id: '/passwords', path: '/passwords', @@ -327,6 +333,7 @@ export interface FileRoutesByFullPath { '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute + '/settings/link': typeof AuthedSettingsLinkRoute '/settings/passwords': typeof AuthedSettingsPasswordsRoute '/sign-in/$requestId': typeof AuthedSignInRequestIdRoute '/admin/': typeof AuthedAdminIndexRoute @@ -372,6 +379,7 @@ export interface FileRoutesByTo { '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute + '/settings/link': typeof AuthedSettingsLinkRoute '/settings/passwords': typeof AuthedSettingsPasswordsRoute '/sign-in/$requestId': typeof AuthedSignInRequestIdRoute '/admin': typeof AuthedAdminIndexRoute @@ -421,6 +429,7 @@ export interface FileRoutesById { '/_authed/admin/people': typeof AuthedAdminPeopleRoute '/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute '/_authed/admin/skills': typeof AuthedAdminSkillsRoute + '/_authed/settings/link': typeof AuthedSettingsLinkRoute '/_authed/settings/passwords': typeof AuthedSettingsPasswordsRoute '/_authed/sign-in/$requestId': typeof AuthedSignInRequestIdRoute '/_authed/_app/': typeof AuthedAppIndexRoute @@ -471,6 +480,7 @@ export interface FileRouteTypes { | '/admin/people' | '/admin/playground' | '/admin/skills' + | '/settings/link' | '/settings/passwords' | '/sign-in/$requestId' | '/admin/' @@ -516,6 +526,7 @@ export interface FileRouteTypes { | '/admin/people' | '/admin/playground' | '/admin/skills' + | '/settings/link' | '/settings/passwords' | '/sign-in/$requestId' | '/admin' @@ -564,6 +575,7 @@ export interface FileRouteTypes { | '/_authed/admin/people' | '/_authed/admin/playground' | '/_authed/admin/skills' + | '/_authed/settings/link' | '/_authed/settings/passwords' | '/_authed/sign-in/$requestId' | '/_authed/_app/' @@ -785,6 +797,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsIndexRouteImport parentRoute: typeof AuthedSettingsRouteRoute } + '/_authed/settings/link': { + id: '/_authed/settings/link' + path: '/link' + fullPath: '/settings/link' + preLoaderRoute: typeof AuthedSettingsLinkRouteImport + parentRoute: typeof AuthedSettingsRouteRoute + } '/_authed/settings/passwords': { id: '/_authed/settings/passwords' path: '/passwords' @@ -974,6 +993,7 @@ const AuthedAdminRouteRouteWithChildren = AuthedAdminRouteRoute._addFileChildren(AuthedAdminRouteRouteChildren) interface AuthedSettingsRouteRouteChildren { + AuthedSettingsLinkRoute: typeof AuthedSettingsLinkRoute AuthedSettingsPasswordsRoute: typeof AuthedSettingsPasswordsRoute AuthedSettingsIndexRoute: typeof AuthedSettingsIndexRoute AuthedSettingsComponentsGalleryNameRoute: typeof AuthedSettingsComponentsGalleryNameRoute @@ -983,6 +1003,7 @@ interface AuthedSettingsRouteRouteChildren { } const AuthedSettingsRouteRouteChildren: AuthedSettingsRouteRouteChildren = { + AuthedSettingsLinkRoute: AuthedSettingsLinkRoute, AuthedSettingsPasswordsRoute: AuthedSettingsPasswordsRoute, AuthedSettingsIndexRoute: AuthedSettingsIndexRoute, AuthedSettingsComponentsGalleryNameRoute: diff --git a/app/src/routes/_authed/settings/link.tsx b/app/src/routes/_authed/settings/link.tsx new file mode 100644 index 000000000..f35957e49 --- /dev/null +++ b/app/src/routes/_authed/settings/link.tsx @@ -0,0 +1,83 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { PageSection, PageShell } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { confirmLinkState } from "@/lib/identity/link-state"; +import { confirmChallengeMutationOptions } from "@/lib/identity/mutations"; +import { challengeQueryOptions } from "@/lib/identity/queries"; + +/** + * The browser half of a link a person started from a chat app. + * + * Confirming here proves which OpenBot user is asking; the link exists only once they finish from + * the outside account too. Nothing happens on load: opening the page is not consent. + */ +export const Route = createFileRoute("/_authed/settings/link")({ + component: RouteComponent, + validateSearch: (search: Record): { token?: string } => + typeof search.token === "string" ? { token: search.token } : {}, +}); + +function RouteComponent() { + const { token } = Route.useSearch(); + const valid = !!token && /^[A-Za-z0-9_-]{43}$/.test(token); + const peek = useQuery({ + ...challengeQueryOptions(token ?? ""), + enabled: valid, + }); + const confirm = useMutation(confirmChallengeMutationOptions()); + const state = confirmLinkState({ + token, + peek: { status: peek.status, data: peek.data }, + confirm: { status: confirm.status, hint: confirm.data?.hint }, + }); + + return ( + + + {state.kind === "invalid" && ( +

+ This link is not valid. +

+ )} + {state.kind === "expired" && ( +

+ This link expired or was already used. Ask for a new one. +

+ )} + {(state.kind === "ready" || state.kind === "confirming") && ( + <> +

+ Only confirm if you asked for this link from your own{" "} + {state.title} account. You will then finish from {state.title}. +

+ + + )} + {state.kind === "failed" && ( +

+ This link expired, was already used, or was confirmed by a different + account. Ask for a new one. +

+ )} + {state.kind === "done" && ( + <> +

+ Confirmed. The link is not active until you finish from{" "} + {state.title}. +

+ {state.hint && ( +
{state.hint}
+ )} + + )} +
+
+ ); +} From 13cc1e39646069cab84178a92eadb83cad63d4be Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:40:51 -0300 Subject: [PATCH 010/101] feat(app): add linked accounts section --- .../settings/linked-accounts.test.tsx | 31 +++++++ .../components/settings/linked-accounts.tsx | 91 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 app/src/components/settings/linked-accounts.test.tsx create mode 100644 app/src/components/settings/linked-accounts.tsx diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx new file mode 100644 index 000000000..96f42f6e1 --- /dev/null +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { linkedAccountDescription } from "./linked-accounts"; + +const base = { + id: "1", + provider: "github" as const, + title: "GitHub", + createdAt: "2026-10-08T00:00:00.000Z", +}; + +test("an active link shows its handle", () => { + expect( + linkedAccountDescription({ ...base, handle: "dana", status: "active" }), + ).toBe("@dana"); +}); + +test("a link without a handle says it is linked", () => { + expect( + linkedAccountDescription({ ...base, handle: null, status: "active" }), + ).toBe("Linked"); +}); + +test("a link needing reconnection says so first", () => { + expect( + linkedAccountDescription({ + ...base, + handle: "dana", + status: "needs_reconnect", + }), + ).toBe("Needs reconnecting · @dana"); +}); diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx new file mode 100644 index 000000000..b001d6d7c --- /dev/null +++ b/app/src/components/settings/linked-accounts.tsx @@ -0,0 +1,91 @@ +import { IconBrandGithub, IconBrandSlack } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { PageRows, PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { unlinkMutationOptions } from "@/lib/identity/mutations"; +import { + type LinkedAccount, + linkedAccountsQueryOptions, +} from "@/lib/identity/queries"; +import { queryClient } from "@/query-client"; + +export function linkedAccountDescription(account: LinkedAccount): string { + const who = account.handle ? `@${account.handle}` : "Linked"; + return account.status === "needs_reconnect" + ? `Needs reconnecting · ${who}` + : who; +} + +/** + * Who you are in the chat apps and code hosts this deployment talks to. + * + * Separate from the connector rows above it: those are services a Bot reads as you, these are how + * this deployment recognises you when a message or an event arrives from somewhere else. + */ +export function LinkedAccountsSection() { + const links = useQuery(linkedAccountsQueryOptions()); + const unlink = useMutation(unlinkMutationOptions(queryClient)); + if (links.isPending) return null; + if (links.error) { + return ( + +

+ Your linked accounts could not be loaded. +

+
+ ); + } + // Deliberately hidden when nothing is linked. + if (links.data.length === 0) return null; + return ( + + + {links.data.map((account, index) => ( +
+ {index > 0 ? : null} + + + {account.provider === "slack" ? ( + + ) : ( + + )} + + + {account.title} + + {linkedAccountDescription(account)} + + + + + + +
+ ))} +
+ {unlink.error ? ( +

+ {unlink.error.message} +

+ ) : null} +
+ ); +} From 8894499cadd0644d655f3e81475289477a7c661a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:43:51 -0300 Subject: [PATCH 011/101] fix(server): serialize one person's links within a realm --- server/src/identity/store.ts | 48 +++++++++++++------ .../tests/identity-store.integration.test.ts | 26 ++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index a34193f61..740a0e5a6 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { CredentialRefusedError, type CredentialStore } from "../credentials"; import type { Database } from "../db/client"; import { identityLinks } from "../db/schema"; @@ -40,15 +40,24 @@ function toLink(row: typeof identityLinks.$inferSelect): IdentityLink { }; } -/** Postgres unique_violation. Bun's driver puts the SQLSTATE in `errno`; drizzle wraps it as `cause`. */ -function isUniqueViolation(error: unknown) { - const sqlState = (value: unknown) => - (value as { errno?: unknown } | null | undefined)?.errno; - return ( - sqlState(error) === "23505" || - sqlState((error as { cause?: unknown } | null | undefined)?.cause) === - "23505" - ); +/** + * The violated constraint's name when `error` is a Postgres unique_violation (SQLSTATE 23505), else + * null. Bun's driver puts the SQLSTATE in `errno` and the name in `constraint`; drizzle wraps the + * driver error as `cause`. + */ +function uniqueViolationConstraint(error: unknown): string | null { + type Driver = { errno?: unknown; constraint?: unknown }; + const candidates = [ + error as Driver | null | undefined, + (error as { cause?: Driver } | null | undefined)?.cause, + ]; + for (const candidate of candidates) { + if (candidate?.errno === "23505") + return typeof candidate.constraint === "string" + ? candidate.constraint + : ""; + } + return null; } async function revokeQuietly( @@ -68,10 +77,15 @@ async function revokeQuietly( /** * Link `identity` to `userId` inside `transaction`. * - * The identity row is locked first. Linked to somebody else: refused. Linked to this person: updated - * in place. Otherwise this person's older link in the same realm, if any, is removed with its - * credential, and the new link inserted. A racing insert of the same identity surfaces as a unique - * violation, which is the same refusal. + * Two races are handled. Two people racing for one identity: the identity row is locked first, and a + * racing insert surfaces as a unique violation on the identity index, which is a refusal + * (IdentityConflictError). One person racing two identities in one realm: serialized by a + * per-person-per-realm advisory lock taken before any row lock, so the later link sees the earlier + * one as `previous` and replaces it. + * + * Under the lock: linked to somebody else: refused. Linked to this person: updated in place. + * Otherwise this person's older link in the same realm, if any, is removed with its credential, and + * the new link inserted. */ export async function writeLink( transaction: Transaction, @@ -84,6 +98,9 @@ export async function writeLink( credentialId?: string | null; }, ): Promise { + await transaction.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`${userId}\u001f${identity.provider}\u001f${identity.realm}`}, 0))`, + ); const [existing] = await transaction .select() .from(identityLinks) @@ -143,7 +160,8 @@ export async function writeLink( .returning(); return toLink(inserted as typeof identityLinks.$inferSelect); } catch (error) { - if (isUniqueViolation(error)) throw new IdentityConflictError(); + if (uniqueViolationConstraint(error) === "identity_links_identity_idx") + throw new IdentityConflictError(); throw error; } } diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index a402369fa..4a16d9fe9 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -131,6 +131,32 @@ test("linkVerified replaces a person's older link in the same realm and revokes expect(await vault.isLive(oldCredential)).toBe(false); }); +test("concurrent links by one person in one realm: the later replaces the earlier", async () => { + const person = user(); + const [first, second] = [await credential(), await credential()] as [ + string, + string, + ]; + const [a, b] = [github(), github()]; + const [linkA, linkB] = await Promise.all([ + store.linkVerified(a, person, { method: "oauth", credentialId: first }), + store.linkVerified(b, person, { method: "oauth", credentialId: second }), + ]); + const mine = (await store.identitiesFor(person, "github")).filter( + (row) => row.realm === realm, + ); + expect(mine).toHaveLength(1); + const [remaining] = mine; + expect([linkA.id, linkB.id]).toContain(remaining?.id); + const live = [first, second].filter((id) => id === remaining?.credentialId); + expect(live).toHaveLength(1); + const liveness = await Promise.all( + [first, second].map((id) => vault.isLive(id)), + ); + expect(liveness.filter(Boolean)).toHaveLength(1); + expect(await vault.isLive(remaining?.credentialId as string)).toBe(true); +}); + test("markNeedsReconnect is visible to linkedUser; relinking makes it active again", async () => { const person = user(); const identity = github(); From 9c49f63152f2b3eda567e341ead9ed5dc22851c0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:45:05 -0300 Subject: [PATCH 012/101] feat(server): add identity link challenges --- server/src/identity/store.ts | 127 +++++++++++++- .../identity-challenges.integration.test.ts | 159 ++++++++++++++++++ 2 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 server/tests/identity-challenges.integration.test.ts diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index 740a0e5a6..b60631a51 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -1,14 +1,15 @@ -import { randomUUID } from "node:crypto"; -import { and, eq, sql } from "drizzle-orm"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { and, eq, gt, isNull, or, sql } from "drizzle-orm"; import { CredentialRefusedError, type CredentialStore } from "../credentials"; import type { Database } from "../db/client"; -import { identityLinks } from "../db/schema"; +import { identityLinkChallenges, identityLinks } from "../db/schema"; import { acceptsMethod, parseIdentity } from "./providers"; import { type Identity, IdentityConflictError, IdentityInputError, type IdentityLink, + IdentityLinkError, type IdentityProvider, type LinkMethod, type LinkStatus, @@ -166,6 +167,11 @@ export async function writeLink( } } +function tokenHash(token: string) { + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new IdentityLinkError(); + return createHash("sha256").update(token).digest("hex"); +} + export function createIdentityStore(database: Database, credentials: Revoke) { return { async linkedUser( @@ -238,6 +244,121 @@ export function createIdentityStore(database: Database, credentials: Revoke) { return true; }); }, + + async beginChallenge( + value: Identity, + handle: string | null = null, + ): Promise<{ token: string; expiresAt: Date }> { + const identity = parseIdentity(value); + if (!acceptsMethod(identity.provider, "challenge")) + throw new IdentityInputError( + "This provider does not accept that kind of proof.", + ); + const token = randomBytes(32).toString("base64url"); + const [challenge] = await database + .insert(identityLinkChallenges) + .values({ + tokenHash: tokenHash(token), + provider: identity.provider, + realm: identity.realm, + subject: identity.subject, + handle: handle?.slice(0, 256) ?? null, + expiresAt: sql`clock_timestamp() + interval '10 minutes'`, + }) + .returning({ expiresAt: identityLinkChallenges.expiresAt }); + if (!challenge) throw new IdentityLinkError(); + return { token, expiresAt: challenge.expiresAt }; + }, + + async peekChallenge( + token: string, + ): Promise<{ provider: IdentityProvider; handle: string | null } | null> { + let hash: string; + try { + hash = tokenHash(token); + } catch { + return null; + } + const [challenge] = await database + .select({ + provider: identityLinkChallenges.provider, + handle: identityLinkChallenges.handle, + }) + .from(identityLinkChallenges) + .where( + and( + eq(identityLinkChallenges.tokenHash, hash), + gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), + ), + ); + return challenge + ? { + provider: challenge.provider as IdentityProvider, + handle: challenge.handle, + } + : null; + }, + + /** The route authenticates this user; no link exists until completeChallenge. */ + async confirmChallenge(token: string, userId: string): Promise { + if (!userId.trim()) throw new IdentityLinkError(); + const [challenge] = await database + .update(identityLinkChallenges) + .set({ confirmedUserId: userId }) + .where( + and( + eq(identityLinkChallenges.tokenHash, tokenHash(token)), + gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), + or( + isNull(identityLinkChallenges.confirmedUserId), + eq(identityLinkChallenges.confirmedUserId, userId), + ), + ), + ) + .returning({ tokenHash: identityLinkChallenges.tokenHash }); + if (!challenge) throw new IdentityLinkError(); + }, + + /** Consumption and link creation in one transaction, so competing challenges serialize. */ + async completeChallenge( + token: string, + value: Identity, + ): Promise { + const identity = parseIdentity(value); + const hash = tokenHash(token); + return database.transaction(async (transaction) => { + const [challenge] = await transaction + .select() + .from(identityLinkChallenges) + .where(eq(identityLinkChallenges.tokenHash, hash)) + .for("update"); + if ( + !challenge?.confirmedUserId || + challenge.provider !== identity.provider || + challenge.realm !== identity.realm || + challenge.subject !== identity.subject + ) + throw new IdentityLinkError(); + // The real clock after the row lock, not the transaction's start time. + const [consumed] = await transaction + .delete(identityLinkChallenges) + .where( + and( + eq(identityLinkChallenges.tokenHash, hash), + gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), + ), + ) + .returning({ tokenHash: identityLinkChallenges.tokenHash }); + if (!consumed) throw new IdentityLinkError(); + return writeLink( + transaction, + credentials, + identity, + challenge.confirmedUserId, + { method: "challenge", handle: challenge.handle }, + ); + }); + }, }; } diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts new file mode 100644 index 000000000..42c54811f --- /dev/null +++ b/server/tests/identity-challenges.integration.test.ts @@ -0,0 +1,159 @@ +import { afterAll, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { identityLinkChallenges, identityLinks } from "../src/db/schema"; +import { createIdentityStore } from "../src/identity/store"; +import { + type Identity, + IdentityInputError, + IdentityLinkError, +} from "../src/identity/types"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const store = createIdentityStore(database, createCredentialStore(database)); +const realm = `test-${randomUUID()}`; + +afterAll(async () => { + await database + .delete(identityLinkChallenges) + .where(eq(identityLinkChallenges.realm, realm)); + await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); + await database.$client.close(); +}); + +const slack = (subject = randomUUID()): Identity => ({ + provider: "slack", + realm, + subject, +}); + +test("extra identity properties cannot pre-confirm an application account", async () => { + const sender = { ...slack(), confirmedUserId: "forged-user" }; + const { token } = await store.beginChallenge(sender); + await expect(store.completeChallenge(token, sender)).rejects.toThrow( + IdentityLinkError, + ); + expect(await store.linkedUser(sender)).toBeNull(); +}); + +test("a provider that does not accept challenges cannot begin one", async () => { + await expect( + store.beginChallenge({ provider: "github", realm, subject: "1" }), + ).rejects.toThrow(IdentityInputError); +}); + +test("linking needs both confirmations, stores only a hash, and consumes once", async () => { + const sender = slack(); + const before = Date.now(); + const challenge = await store.beginChallenge(sender, "dana"); + expect(Buffer.from(challenge.token, "base64url").length).toBe(32); + expect(challenge.expiresAt.getTime()).toBeGreaterThanOrEqual( + before + 599_000, + ); + expect(challenge.expiresAt.getTime()).toBeLessThanOrEqual( + Date.now() + 600_000, + ); + const [saved] = await database + .select() + .from(identityLinkChallenges) + .where( + eq( + identityLinkChallenges.tokenHash, + createHash("sha256").update(challenge.token).digest("hex"), + ), + ); + expect(saved).toBeDefined(); + expect(JSON.stringify(saved)).not.toContain(challenge.token); + expect(await store.peekChallenge(challenge.token)).toEqual({ + provider: "slack", + handle: "dana", + }); + await expect( + store.completeChallenge(challenge.token, sender), + ).rejects.toThrow(IdentityLinkError); + await store.confirmChallenge(challenge.token, "user-a"); + await store.confirmChallenge(challenge.token, "user-a"); + expect(await store.linkedUser(sender)).toBeNull(); + const link = await store.completeChallenge(challenge.token, sender); + expect(link.verifiedBy).toBe("challenge"); + expect(link.handle).toBe("dana"); + expect((await store.linkedUser(sender))?.userId).toBe("user-a"); + await expect( + store.completeChallenge(challenge.token, sender), + ).rejects.toThrow(IdentityLinkError); + await expect( + store.confirmChallenge(challenge.token, "user-a"), + ).rejects.toThrow(IdentityLinkError); + expect(await store.peekChallenge(challenge.token)).toBeNull(); +}); + +test("confirmation cannot change the signed-in application user", async () => { + const sender = slack(); + const { token } = await store.beginChallenge(sender); + const results = await Promise.allSettled([ + store.confirmChallenge(token, "user-a"), + store.confirmChallenge(token, "user-b"), + ]); + expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); + await store.completeChallenge(token, sender); + expect(["user-a", "user-b"]).toContain( + (await store.linkedUser(sender))?.userId, + ); +}); + +test.each(["provider", "realm", "subject"] as const)( + "a challenge refuses a different %s without consuming it", + async (field) => { + const sender = slack(); + const { token } = await store.beginChallenge(sender); + await store.confirmChallenge(token, "user-a"); + const other = + field === "provider" + ? { ...sender, provider: "github" as const } + : { ...sender, [field]: "other" }; + await expect(store.completeChallenge(token, other)).rejects.toThrow(); + await store.completeChallenge(token, sender); + expect((await store.linkedUser(sender))?.userId).toBe("user-a"); + }, +); + +test("an expired challenge can be neither peeked, confirmed nor completed", async () => { + const sender = slack(); + const { token } = await store.beginChallenge(sender); + await database + .update(identityLinkChallenges) + .set({ expiresAt: sql`clock_timestamp() - interval '1 second'` }) + .where( + eq( + identityLinkChallenges.tokenHash, + createHash("sha256").update(token).digest("hex"), + ), + ); + expect(await store.peekChallenge(token)).toBeNull(); + await expect(store.confirmChallenge(token, "user-a")).rejects.toThrow( + IdentityLinkError, + ); +}); + +test("a malformed token is refused before any query", async () => { + await expect(store.confirmChallenge("short", "user-a")).rejects.toThrow( + IdentityLinkError, + ); + expect(await store.peekChallenge("short")).toBeNull(); +}); + +test("completing a second challenge in the same realm replaces the person's older link", async () => { + const first = slack(); + const one = await store.beginChallenge(first); + await store.confirmChallenge(one.token, "user-z"); + await store.completeChallenge(one.token, first); + const second = slack(); + const two = await store.beginChallenge(second); + await store.confirmChallenge(two.token, "user-z"); + await store.completeChallenge(two.token, second); + expect(await store.linkedUser(first)).toBeNull(); + expect((await store.linkedUser(second))?.userId).toBe("user-z"); +}); From 78f9b9ba27ac3208b41b84638cced12b464d87ee Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:45:11 -0300 Subject: [PATCH 013/101] feat(app): list and disconnect linked accounts --- app/src/routes/_authed/settings/connected-accounts/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index f77898ddf..f18a1327a 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -15,6 +15,7 @@ import { } from "@/components/layout/page-shell"; import { RowMark } from "@/components/layout/row-mark"; import { PluginLogo } from "@/components/plugins/plugin-logo"; +import { LinkedAccountsSection } from "@/components/settings/linked-accounts"; import { InputGroup, InputGroupAddon, @@ -281,6 +282,7 @@ function RouteComponent() { )} )} + ); } From ee9a73eb3bb2548025a8f7e52ad0c7a79eae3e18 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:47:04 -0300 Subject: [PATCH 014/101] feat(server): add linked account routes --- server/src/identity/routes.ts | 109 +++++++++++++++++++++ server/tests/identity-routes.test.ts | 138 +++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 server/src/identity/routes.ts create mode 100644 server/tests/identity-routes.test.ts diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts new file mode 100644 index 000000000..94da9f5d8 --- /dev/null +++ b/server/src/identity/routes.ts @@ -0,0 +1,109 @@ +import { Hono, type MiddlewareHandler } from "hono"; +import { z } from "zod"; +import type { AppVariables } from "../auth/guards"; +import { PROVIDERS } from "./providers"; +import type { IdentityStore } from "./store"; +import type { IdentityProvider } from "./types"; + +const tokenBody = z.strictObject({ + token: z.string().regex(/^[A-Za-z0-9_-]{43}$/), +}); + +/** + * A person's own linked accounts, and the browser half of a chat-started link. + * + * Every route is the asker's own: there is no administrator view of somebody else's links and no + * way to link an account on another person's behalf. What leaves here is what the settings page + * draws; realm, subject and credential stay on the server. + */ +export function identityRoutes( + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + store?: Pick< + IdentityStore, + "identitiesFor" | "unlink" | "peekChallenge" | "confirmChallenge" + >, +) { + const app = new Hono<{ Variables: AppVariables }>(); + app.use("*", async (context, next) => { + context.header("cache-control", "no-store"); + await next(); + }); + app.use("*", requireUser); + if (!store) { + app.all("*", (context) => + context.json({ error: "Linked accounts are not available." }, 503), + ); + return app; + } + const live = store; + + app.get("/links", async (context) => { + const links = await live.identitiesFor(context.var.actor.id); + return context.json({ + links: links.map((link) => ({ + id: link.id, + provider: link.provider, + title: PROVIDERS[link.provider].title, + handle: link.handle, + status: link.status, + createdAt: link.createdAt.toISOString(), + })), + }); + }); + + app.delete("/links/:id", async (context) => { + const removed = await live.unlink( + context.var.actor.id, + context.req.param("id"), + ); + if (!removed) + return context.json({ error: "Linked account not found." }, 404); + return context.body(null, 204); + }); + + app.post("/challenges/peek", async (context) => { + const parsed = tokenBody.safeParse( + await context.req.json().catch(() => null), + ); + if (!parsed.success) return context.json({ error: "Invalid link." }, 400); + const challenge = await live.peekChallenge(parsed.data.token); + if (!challenge) + return context.json( + { error: "This link expired or was already used. Ask for a new one." }, + 404, + ); + return context.json({ + challenge: { + provider: challenge.provider, + title: PROVIDERS[challenge.provider as IdentityProvider].title, + handle: challenge.handle, + }, + }); + }); + + app.post("/challenges/confirm", async (context) => { + const parsed = tokenBody.safeParse( + await context.req.json().catch(() => null), + ); + if (!parsed.success) return context.json({ error: "Invalid link." }, 400); + const challenge = await live.peekChallenge(parsed.data.token); + try { + await live.confirmChallenge(parsed.data.token, context.var.actor.id); + } catch { + return context.json( + { + error: + "This link expired, was already used, or was confirmed by a different account. Ask for a new one.", + }, + 409, + ); + } + const hint = challenge + ? (PROVIDERS[challenge.provider].completionHint?.(parsed.data.token) ?? + null) + : null; + return context.json({ confirmation: { hint } }); + }); + + return app; +} diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts new file mode 100644 index 000000000..65e31395c --- /dev/null +++ b/server/tests/identity-routes.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import { identityRoutes } from "../src/identity/routes"; +import { type IdentityLink, IdentityLinkError } from "../src/identity/types"; + +const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, +) => { + context.set("actor", { + id: "person", + email: "p@example.com", + role: "user", + } as never); + await next(); +}; +const token = "a".repeat(43); +const link: IdentityLink = { + id: "link-1", + provider: "github", + realm: "github.com", + subject: "42", + userId: "person", + handle: "dana", + verifiedBy: "oauth", + credentialId: "secret-credential-id", + status: "active", + createdAt: new Date("2026-10-08T00:00:00Z"), + updatedAt: new Date("2026-10-08T00:00:00Z"), +}; + +function fixture() { + const calls: string[] = []; + const app = identityRoutes(signedIn, { + identitiesFor: async (userId) => (userId === "person" ? [link] : []), + unlink: async (userId, id) => { + calls.push(`unlink:${userId}:${id}`); + return userId === "person" && id === "link-1"; + }, + peekChallenge: async (t) => + t === token ? { provider: "slack", handle: "dana" } : null, + confirmChallenge: async (t, userId) => { + calls.push(`confirm:${userId}`); + if (t !== token) throw new IdentityLinkError(); + }, + }); + return { app, calls }; +} + +const post = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +test("links lists the asker's links without subject, realm or credential", async () => { + const response = await fixture().app.request("/links"); + expect(response.status).toBe(200); + const body = (await response.json()) as { links: Record[] }; + expect(body.links).toEqual([ + { + id: "link-1", + provider: "github", + title: "GitHub", + handle: "dana", + status: "active", + createdAt: "2026-10-08T00:00:00.000Z", + }, + ]); + expect(JSON.stringify(body)).not.toContain("secret-credential-id"); +}); + +test("DELETE of the asker's own link is 204", async () => { + const f = fixture(); + expect( + (await f.app.request("/links/link-1", { method: "DELETE" })).status, + ).toBe(204); + expect(f.calls).toEqual(["unlink:person:link-1"]); +}); + +test("DELETE of another person's link is 404", async () => { + expect( + (await fixture().app.request("/links/someone-elses", { method: "DELETE" })) + .status, + ).toBe(404); +}); + +test("peek names the provider of a live challenge", async () => { + const response = await fixture().app.request( + "/challenges/peek", + post({ token }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + challenge: { provider: "slack", title: "Slack", handle: "dana" }, + }); +}); + +test("peek of an expired token is 404", async () => { + const response = await fixture().app.request( + "/challenges/peek", + post({ token: "b".repeat(43) }), + ); + expect(response.status).toBe(404); +}); + +test("confirm records the signed-in user and returns the provider's hint", async () => { + const f = fixture(); + const response = await f.app.request("/challenges/confirm", post({ token })); + expect(response.status).toBe(200); + const body = (await response.json()) as { confirmation: { hint: string } }; + expect(body.confirmation.hint).toContain(`/link ${token}`); + expect(f.calls).toEqual(["confirm:person"]); +}); + +test("confirm of a bad token is 409 with a sentence, and a malformed body is 400", async () => { + const f = fixture(); + const bad = await f.app.request( + "/challenges/confirm", + post({ token: "c".repeat(43) }), + ); + expect(bad.status).toBe(409); + expect(((await bad.json()) as { error: string }).error).toContain("expired"); + expect((await f.app.request("/challenges/confirm", post({}))).status).toBe( + 400, + ); +}); + +test("responses are not cached", async () => { + const response = await fixture().app.request("/links"); + expect(response.headers.get("cache-control")).toBe("no-store"); +}); + +test("without a store every route is 503", async () => { + const app = identityRoutes(signedIn); + expect((await app.request("/links")).status).toBe(503); +}); From fc081557e6d5488b95743a53353ce98513438cae Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:49:03 -0300 Subject: [PATCH 015/101] feat(server): serve linked accounts under /api/identity --- server/src/app.ts | 10 ++++++++++ server/src/index.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/server/src/app.ts b/server/src/app.ts index 636b8a3e3..4dbf8be53 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -80,6 +80,8 @@ import { createTranscriptionProvider } from "./dictation/provider"; import { createDictationRoutes } from "./dictation/routes"; import type { HostAccessBroker } from "./host-access/broker"; import { createHostAccessRoutes } from "./host-access/routes"; +import { identityRoutes } from "./identity/routes"; +import type { IdentityStore } from "./identity/store"; import { createIntelligenceClient } from "./intelligence-client"; import { createLearningRoutes, @@ -421,6 +423,8 @@ export function createApp( * resolver still answers from `config.selfHostBanner` rather than asking the network. */ selfHostBanner?: SelfHostBanner, + /** A person's own linked accounts. Absent answers 503 on /api/identity. */ + identity?: IdentityStore, ) { const app = new Hono<{ Variables: AppVariables }>(); mountDesktopConnectionFailure(app, desktopHostToken); @@ -677,6 +681,12 @@ export function createApp( userPreferencesRoutes(requireUser, userPreferences), ); + /* + * A person's own linked accounts at outside providers (Slack, GitHub). Theirs alone: there is no + * administrator view and no linking on somebody else's behalf. + */ + app.route("/api/identity", identityRoutes(requireUser, identity)); + app.get("/api/settings/instructions", requireUser, async (context) => { if (!userInstructions) { return context.json( diff --git a/server/src/index.ts b/server/src/index.ts index 1857e5fec..a0695c9b6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -176,6 +176,7 @@ import { HostAccessRefusedError, } from "./host-access/broker"; import { hostAccessTools } from "./host-access/tools"; +import { createIdentityStore } from "./identity/store"; import { createIntelligenceClient, observeIntelligenceAuthentication, @@ -338,6 +339,7 @@ await initializeDevActorUser(database, config.singleUser); // The vault, built before the agent store because a customer's agent may sit behind a key and that // key belongs here rather than on the agent row. See agents/auth-header.ts. const credentialStore = createCredentialStore(database); +const identityStore = createIdentityStore(database, credentialStore); const agentVault = { store: credentialStore, reader: credentialStore, @@ -3021,6 +3023,8 @@ const app = createApp( enabled: config.selfHostBanner, entitlements: () => selfHostBannerIntelligence.getRuntimeEntitlements(), }), + + identityStore, ); /** From f2009fec0c7d7a06c9187a24941e9f09cd0b9452 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:51:58 -0300 Subject: [PATCH 016/101] chore(server): drop plan path comments --- server/src/db/schema/identity.ts | 1 - server/tests/identity-providers.test.ts | 1 - server/tests/identity-schema.integration.test.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/server/src/db/schema/identity.ts b/server/src/db/schema/identity.ts index 0ae109b8f..12405789e 100644 --- a/server/src/db/schema/identity.ts +++ b/server/src/db/schema/identity.ts @@ -1,4 +1,3 @@ -// server/src/db/schema/identity.ts /** * Accounts at outside providers, linked to OpenBot users. * diff --git a/server/tests/identity-providers.test.ts b/server/tests/identity-providers.test.ts index 8362d2efc..1f82f65cd 100644 --- a/server/tests/identity-providers.test.ts +++ b/server/tests/identity-providers.test.ts @@ -1,4 +1,3 @@ -// server/tests/identity-providers.test.ts import { expect, test } from "bun:test"; import { acceptsMethod, diff --git a/server/tests/identity-schema.integration.test.ts b/server/tests/identity-schema.integration.test.ts index 6235303fd..847c9f3f2 100644 --- a/server/tests/identity-schema.integration.test.ts +++ b/server/tests/identity-schema.integration.test.ts @@ -1,4 +1,3 @@ -// server/tests/identity-schema.integration.test.ts import { afterAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; From fa517ef16979a9ac554a0cf82de22e0e77cc3237 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:52:09 -0300 Subject: [PATCH 017/101] chore(app): drop plan path comments --- app/src/lib/identity/link-state.test.ts | 1 - app/src/lib/identity/link-state.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/app/src/lib/identity/link-state.test.ts b/app/src/lib/identity/link-state.test.ts index 7e87fc64f..f63cd8018 100644 --- a/app/src/lib/identity/link-state.test.ts +++ b/app/src/lib/identity/link-state.test.ts @@ -1,4 +1,3 @@ -// app/src/lib/identity/link-state.test.ts import { expect, test } from "bun:test"; import { confirmLinkState } from "./link-state"; diff --git a/app/src/lib/identity/link-state.ts b/app/src/lib/identity/link-state.ts index f941241d0..9c6d6434d 100644 --- a/app/src/lib/identity/link-state.ts +++ b/app/src/lib/identity/link-state.ts @@ -1,4 +1,3 @@ -// app/src/lib/identity/link-state.ts type Peek = { status: "pending" | "error" | "success"; data?: { title: string }; From 2fda0cc55551f455618155d8df9fd2e5d8e55295 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:52:15 -0300 Subject: [PATCH 018/101] fix(app): keep a confirmed link confirmed --- app/src/lib/identity/link-state.test.ts | 17 +++++++++++++++++ app/src/lib/identity/link-state.ts | 9 +++++++-- app/src/lib/identity/queries.ts | 1 + app/src/routes/_authed/settings/link.tsx | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/app/src/lib/identity/link-state.test.ts b/app/src/lib/identity/link-state.test.ts index f63cd8018..78cbc2ceb 100644 --- a/app/src/lib/identity/link-state.test.ts +++ b/app/src/lib/identity/link-state.test.ts @@ -57,3 +57,20 @@ test("confirm states", () => { }), ).toEqual({ kind: "done", title: "Slack", hint: "do X" }); }); + +test("a confirmed link stays done when the peek later errors", () => { + expect( + confirmLinkState({ + token, + peek: { status: "error" }, + confirm: { status: "success", hint: "do X" }, + }), + ).toEqual({ kind: "done", title: undefined, hint: "do X" }); + expect( + confirmLinkState({ + token, + peek: { status: "error", data: { title: "Slack" } }, + confirm: { status: "success" }, + }), + ).toEqual({ kind: "done", title: "Slack", hint: null }); +}); diff --git a/app/src/lib/identity/link-state.ts b/app/src/lib/identity/link-state.ts index 9c6d6434d..fbaadc891 100644 --- a/app/src/lib/identity/link-state.ts +++ b/app/src/lib/identity/link-state.ts @@ -27,6 +27,13 @@ export function confirmLinkState(input: { }): ConfirmLinkState { if (!input.token || !/^[A-Za-z0-9_-]{43}$/.test(input.token)) return { kind: "invalid" }; + // A confirmed link stays done: the challenge is spent, so a later peek refetch would only 404. + if (input.confirm.status === "success") + return { + kind: "done", + title: input.peek.data?.title, + hint: input.confirm.hint ?? null, + }; if (input.peek.status === "pending") return { kind: "loading" }; if (input.peek.status === "error" || !input.peek.data) return { kind: "expired" }; @@ -36,8 +43,6 @@ export function confirmLinkState(input: { return { kind: "confirming" }; case "error": return { kind: "failed" }; - case "success": - return { kind: "done", title, hint: input.confirm.hint ?? null }; default: return { kind: "ready", title }; } diff --git a/app/src/lib/identity/queries.ts b/app/src/lib/identity/queries.ts index 903897081..29a12f42f 100644 --- a/app/src/lib/identity/queries.ts +++ b/app/src/lib/identity/queries.ts @@ -45,5 +45,6 @@ export function challengeQueryOptions(token: string) { fallback: "This link expired or was already used", }), retry: false, + staleTime: Infinity, }); } diff --git a/app/src/routes/_authed/settings/link.tsx b/app/src/routes/_authed/settings/link.tsx index f35957e49..c5dbf3a55 100644 --- a/app/src/routes/_authed/settings/link.tsx +++ b/app/src/routes/_authed/settings/link.tsx @@ -70,7 +70,7 @@ function RouteComponent() { <>

Confirmed. The link is not active until you finish from{" "} - {state.title}. + {state.title ?? "the app you started from"}.

{state.hint && (
{state.hint}
From 34275c6ae98f6d6259ce6e72ba49d624ddd319c0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:52:16 -0300 Subject: [PATCH 019/101] fix(server): surface unexpected confirm failures --- server/src/identity/routes.ts | 7 ++++--- server/tests/identity-routes.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index 94da9f5d8..ae15ab999 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { AppVariables } from "../auth/guards"; import { PROVIDERS } from "./providers"; import type { IdentityStore } from "./store"; -import type { IdentityProvider } from "./types"; +import { IdentityLinkError } from "./types"; const tokenBody = z.strictObject({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/), @@ -75,7 +75,7 @@ export function identityRoutes( return context.json({ challenge: { provider: challenge.provider, - title: PROVIDERS[challenge.provider as IdentityProvider].title, + title: PROVIDERS[challenge.provider].title, handle: challenge.handle, }, }); @@ -89,7 +89,8 @@ export function identityRoutes( const challenge = await live.peekChallenge(parsed.data.token); try { await live.confirmChallenge(parsed.data.token, context.var.actor.id); - } catch { + } catch (error) { + if (!(error instanceof IdentityLinkError)) throw error; return context.json( { error: diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 65e31395c..871b6622f 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -127,6 +127,20 @@ test("confirm of a bad token is 409 with a sentence, and a malformed body is 400 ); }); +test("confirm surfaces an unexpected failure instead of answering 409", async () => { + const app = identityRoutes(signedIn, { + identitiesFor: async () => [], + unlink: async () => false, + peekChallenge: async () => ({ provider: "slack", handle: "dana" }), + confirmChallenge: async () => { + throw new Error("db down"); + }, + }); + const response = await app.request("/challenges/confirm", post({ token })); + expect(response.status).not.toBe(409); + expect(response.status).toBe(500); +}); + test("responses are not cached", async () => { const response = await fixture().app.request("/links"); expect(response.headers.get("cache-control")).toBe("no-store"); From 6ed01a729dcf79a854a5d46bfc2ab1b6bad03c6a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:52:19 -0300 Subject: [PATCH 020/101] fix(app): describe the link confirmation page --- app/src/routes/_authed/settings/link.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/routes/_authed/settings/link.tsx b/app/src/routes/_authed/settings/link.tsx index c5dbf3a55..b9310e35b 100644 --- a/app/src/routes/_authed/settings/link.tsx +++ b/app/src/routes/_authed/settings/link.tsx @@ -33,7 +33,10 @@ function RouteComponent() { }); return ( - + {state.kind === "invalid" && (

@@ -73,7 +76,9 @@ function RouteComponent() { {state.title ?? "the app you started from"}.

{state.hint && ( -
{state.hint}
+
+                {state.hint}
+              
)} )} From 2c75c12ed4dc9fb36ddd949be402a8f53cd6c193 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:52:20 -0300 Subject: [PATCH 021/101] fix(server): refuse cross-site link confirmations --- server/src/identity/routes.ts | 13 +++++++++++ server/tests/identity-routes.test.ts | 35 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index ae15ab999..53e0f6d6d 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -82,6 +82,19 @@ export function identityRoutes( }); app.post("/challenges/confirm", async (context) => { + // Confirming binds a chat identity to the signed-in person, so a page on another site must not + // be able to trigger it with the person's cookies. + const origin = context.req.header("origin"); + if ( + origin + ? origin !== new URL(context.req.url).origin + : context.req.header("sec-fetch-site") === "cross-site" + ) { + return context.json( + { error: "Confirm this link from the OpenBot app." }, + 403, + ); + } const parsed = tokenBody.safeParse( await context.req.json().catch(() => null), ); diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 871b6622f..7cdecae9f 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -141,6 +141,41 @@ test("confirm surfaces an unexpected failure instead of answering 409", async () expect(response.status).toBe(500); }); +test("confirm refuses cross-site requests without confirming", async () => { + for (const headers of [ + { "sec-fetch-site": "cross-site" }, + { origin: "https://evil.example" }, + ]) { + const f = fixture(); + const response = await f.app.request( + "http://openbot.test/challenges/confirm", + { + ...post({ token }), + headers: { "content-type": "application/json", ...headers }, + }, + ); + expect(response.status).toBe(403); + expect(f.calls).toEqual([]); + } +}); + +test("confirm accepts same-origin requests", async () => { + const f = fixture(); + const response = await f.app.request( + "http://openbot.test/challenges/confirm", + { + ...post({ token }), + headers: { + "content-type": "application/json", + origin: "http://openbot.test", + "sec-fetch-site": "same-origin", + }, + }, + ); + expect(response.status).toBe(200); + expect(f.calls).toEqual(["confirm:person"]); +}); + test("responses are not cached", async () => { const response = await fixture().app.request("/links"); expect(response.headers.get("cache-control")).toBe("no-store"); From fbb5afa9f1400e9ee94d872d25d0de799f2c16f3 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:52:20 -0300 Subject: [PATCH 022/101] test(server): pin identity route middleware order --- server/tests/identity-routes.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 7cdecae9f..624ee0208 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -185,3 +185,12 @@ test("without a store every route is 503", async () => { const app = identityRoutes(signedIn); expect((await app.request("/links")).status).toBe(503); }); + +test("auth runs before the missing-store 503 and responses are never cached", async () => { + const app = identityRoutes(async (context) => + context.json({ error: "Sign in." }, 401), + ); + const response = await app.request("/links"); + expect(response.status).toBe(401); + expect(response.headers.get("cache-control")).toBe("no-store"); +}); From 3dc139c602939ba438cea4740432a3c9d21c03b0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:52:38 -0300 Subject: [PATCH 023/101] fix(server): clear stale link challenges --- server/src/identity/store.ts | 11 +++- .../identity-challenges.integration.test.ts | 53 ++++++++++++++----- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index b60631a51..ac7b2418b 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { and, eq, gt, isNull, or, sql } from "drizzle-orm"; +import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; import { CredentialRefusedError, type CredentialStore } from "../credentials"; import type { Database } from "../db/client"; import { identityLinkChallenges, identityLinks } from "../db/schema"; @@ -255,6 +255,15 @@ export function createIdentityStore(database: Database, credentials: Revoke) { "This provider does not accept that kind of proof.", ); const token = randomBytes(32).toString("base64url"); + // No sweep exists, so chat-started links that were never opened would otherwise accumulate. + await database + .delete(identityLinkChallenges) + .where( + lt( + identityLinkChallenges.expiresAt, + sql`clock_timestamp() - interval '1 day'`, + ), + ); const [challenge] = await database .insert(identityLinkChallenges) .values({ diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts index 42c54811f..bb14b3e8d 100644 --- a/server/tests/identity-challenges.integration.test.ts +++ b/server/tests/identity-challenges.integration.test.ts @@ -120,22 +120,47 @@ test.each(["provider", "realm", "subject"] as const)( }, ); -test("an expired challenge can be neither peeked, confirmed nor completed", async () => { - const sender = slack(); - const { token } = await store.beginChallenge(sender); - await database +const hashOf = (token: string) => + createHash("sha256").update(token).digest("hex"); +const expire = (token: string) => + database .update(identityLinkChallenges) .set({ expiresAt: sql`clock_timestamp() - interval '1 second'` }) - .where( - eq( - identityLinkChallenges.tokenHash, - createHash("sha256").update(token).digest("hex"), - ), - ); - expect(await store.peekChallenge(token)).toBeNull(); - await expect(store.confirmChallenge(token, "user-a")).rejects.toThrow( - IdentityLinkError, - ); + .where(eq(identityLinkChallenges.tokenHash, hashOf(token))); + +test("an expired challenge can be neither peeked, confirmed nor completed", async () => { + const sender = slack(); + const confirmed = await store.beginChallenge(sender); + await store.confirmChallenge(confirmed.token, "user-a"); + await expire(confirmed.token); + expect(await store.peekChallenge(confirmed.token)).toBeNull(); + await expect( + store.completeChallenge(confirmed.token, sender), + ).rejects.toThrow(IdentityLinkError); + + const unconfirmed = await store.beginChallenge(slack()); + await expire(unconfirmed.token); + expect(await store.peekChallenge(unconfirmed.token)).toBeNull(); + await expect( + store.confirmChallenge(unconfirmed.token, "user-a"), + ).rejects.toThrow(IdentityLinkError); +}); + +test("beginning a challenge clears challenges that expired over a day ago", async () => { + const stale = `stale-${randomUUID()}`; + await database.insert(identityLinkChallenges).values({ + tokenHash: stale, + provider: "slack", + realm, + subject: randomUUID(), + expiresAt: sql`clock_timestamp() - interval '2 days'`, + }); + await store.beginChallenge(slack()); + const rows = await database + .select() + .from(identityLinkChallenges) + .where(eq(identityLinkChallenges.tokenHash, stale)); + expect(rows).toEqual([]); }); test("a malformed token is refused before any query", async () => { From d5ca067eaed3f7cd4b7758aca6031190a864ed6a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 11:54:23 -0300 Subject: [PATCH 024/101] fix(server): accept trusted app origins when confirming links --- server/src/app.ts | 14 ++++--- server/src/identity/routes.ts | 5 ++- server/tests/identity-routes.test.ts | 62 ++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 4dbf8be53..3a3b21544 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -685,7 +685,14 @@ export function createApp( * A person's own linked accounts at outside providers (Slack, GitHub). Theirs alone: there is no * administrator view and no linking on somebody else's behalf. */ - app.route("/api/identity", identityRoutes(requireUser, identity)); + const trustedAppOrigins = [ + ...(config.auth?.trustedOrigins ?? []), + ...(config.appUrl ? [config.appUrl] : []), + ]; + app.route( + "/api/identity", + identityRoutes(requireUser, identity, trustedAppOrigins), + ); app.get("/api/settings/instructions", requireUser, async (context) => { if (!userInstructions) { @@ -766,10 +773,7 @@ export function createApp( createLearningRoutes( learning, requireUser, - [ - ...(config.auth?.trustedOrigins ?? []), - ...(config.appUrl ? [config.appUrl] : []), - ], + trustedAppOrigins, auditStore, ), ); diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index 53e0f6d6d..07ac64b37 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -22,6 +22,7 @@ export function identityRoutes( IdentityStore, "identitiesFor" | "unlink" | "peekChallenge" | "confirmChallenge" >, + trustedOrigins: readonly string[] = [], ) { const app = new Hono<{ Variables: AppVariables }>(); app.use("*", async (context, next) => { @@ -87,7 +88,9 @@ export function identityRoutes( const origin = context.req.header("origin"); if ( origin - ? origin !== new URL(context.req.url).origin + ? !new Set([new URL(context.req.url).origin, ...trustedOrigins]).has( + origin, + ) : context.req.header("sec-fetch-site") === "cross-site" ) { return context.json( diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 624ee0208..303b7ca8a 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -30,21 +30,25 @@ const link: IdentityLink = { updatedAt: new Date("2026-10-08T00:00:00Z"), }; -function fixture() { +function fixture(trustedOrigins: readonly string[] = []) { const calls: string[] = []; - const app = identityRoutes(signedIn, { - identitiesFor: async (userId) => (userId === "person" ? [link] : []), - unlink: async (userId, id) => { - calls.push(`unlink:${userId}:${id}`); - return userId === "person" && id === "link-1"; - }, - peekChallenge: async (t) => - t === token ? { provider: "slack", handle: "dana" } : null, - confirmChallenge: async (t, userId) => { - calls.push(`confirm:${userId}`); - if (t !== token) throw new IdentityLinkError(); + const app = identityRoutes( + signedIn, + { + identitiesFor: async (userId) => (userId === "person" ? [link] : []), + unlink: async (userId, id) => { + calls.push(`unlink:${userId}:${id}`); + return userId === "person" && id === "link-1"; + }, + peekChallenge: async (t) => + t === token ? { provider: "slack", handle: "dana" } : null, + confirmChallenge: async (t, userId) => { + calls.push(`confirm:${userId}`); + if (t !== token) throw new IdentityLinkError(); + }, }, - }); + trustedOrigins, + ); return { app, calls }; } @@ -176,6 +180,38 @@ test("confirm accepts same-origin requests", async () => { expect(f.calls).toEqual(["confirm:person"]); }); +test("confirm accepts a trusted app origin behind a proxy", async () => { + const f = fixture(["https://openbot.example"]); + const response = await f.app.request( + "http://openbot.test/challenges/confirm", + { + ...post({ token }), + headers: { + "content-type": "application/json", + origin: "https://openbot.example", + }, + }, + ); + expect(response.status).toBe(200); + expect(f.calls).toEqual(["confirm:person"]); +}); + +test("confirm refuses an untrusted origin that differs from the request", async () => { + const f = fixture(); + const response = await f.app.request( + "http://openbot.test/challenges/confirm", + { + ...post({ token }), + headers: { + "content-type": "application/json", + origin: "https://openbot.example", + }, + }, + ); + expect(response.status).toBe(403); + expect(f.calls).toEqual([]); +}); + test("responses are not cached", async () => { const response = await fixture().app.request("/links"); expect(response.headers.get("cache-control")).toBe("no-store"); From 09c31736f4d658b71a69a74ade60e5ac8f9a05af Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:14:20 -0300 Subject: [PATCH 025/101] fix(server): issue link codes to the signed-in person A chat-started link was confirmed by whoever was signed in when they opened its URL, so someone could start a link from their own Slack, send the URL to a victim, and have their Slack identity attached to the victim's OpenBot user once the victim confirmed. OpenBot now issues the code to the signed-in person instead (issueChallenge), and the link is made when that code arrives from their chat account (redeemChallenge). The code is a random UUID so a chat handler can accept `link `, matching the OpenTag Reachability pairing; only its sha256 is stored, it lasts ten minutes, issuing again replaces the person's previous code, and a refused redeem (wrong provider, identity already linked elsewhere) leaves the code unused. The browser confirmation routes and their origin check are removed; no route issues codes until a chat consumer exists. Migration 0052 had not shipped, so it is regenerated rather than followed by 0053. Removed or renamed symbols and their call sites (git grep over server, app and tests, excluding docs/superpowers plans): - beginChallenge, peekChallenge, confirmChallenge, completeChallenge: store.ts, routes.ts, identity-routes and identity-challenges tests; all removed or rewritten. - completionHint -> instruction: providers.ts, routes.ts, identity-providers test. - trustedAppOrigins: app.ts only; Learning mount restored to main's inline origins. - /challenges/peek, /challenges/confirm: routes.ts and the app's queries/mutations (removed in the following commit); the only remaining mention is the test pinning that they 404. --- docs/configuration.md | 14 +- server/drizzle/0052_identity_links.sql | 6 +- server/drizzle/meta/0052_snapshot.json | 45 +-- server/drizzle/meta/_journal.json | 2 +- server/src/app.ts | 14 +- server/src/db/schema/identity.ts | 16 +- server/src/identity/providers.ts | 13 +- server/src/identity/routes.ts | 74 +---- server/src/identity/store.ts | 170 ++++------ server/src/identity/types.ts | 2 +- .../identity-challenges.integration.test.ts | 298 ++++++++++-------- server/tests/identity-providers.test.ts | 10 +- server/tests/identity-routes.test.ts | 159 +--------- 13 files changed, 326 insertions(+), 497 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d722a12d1..98a64aeac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -489,10 +489,16 @@ number opted out, later messages show `opted_out` in the delivery history instea A person's accounts at outside providers — their Slack user, their GitHub account — are linked to their OpenBot user in one place, `identity_links`, and listed under **Linked accounts** on -`/settings/connected-accounts`, where they can disconnect any of them. A link is proven by the -provider's own sign-in (GitHub) or by a two-sided challenge a person starts in a chat app and -confirms at `/settings/link` (Slack). Nobody can link an account on somebody else's behalf, and an -outside account links to one OpenBot user at most. +`/settings/connected-accounts`, where they can disconnect any of them. A link is proven one of two +ways: by the provider's own sign-in (GitHub, when that integration is configured), or by a one-time +code OpenBot issues to the signed-in person, which they send from their chat account (Slack). A +code always links to the person it was issued to, so nobody can link an account on somebody else's +behalf, and an outside account links to one OpenBot user at most. Linking providers are added by +their integrations; until one is configured, the list is empty. + +This complements, rather than replaces, the OpenTag **Reachability** pairing above: that pairing +binds a conversation and Bot for delivery, while a linked account records who a person is at the +provider. ## Text messages and push notifications diff --git a/server/drizzle/0052_identity_links.sql b/server/drizzle/0052_identity_links.sql index a08c73814..b0495c777 100644 --- a/server/drizzle/0052_identity_links.sql +++ b/server/drizzle/0052_identity_links.sql @@ -1,10 +1,7 @@ CREATE TABLE "identity_link_challenges" ( "token_hash" text PRIMARY KEY NOT NULL, "provider" text NOT NULL, - "realm" text NOT NULL, - "subject" text NOT NULL, - "handle" text, - "confirmed_user_id" text, + "user_id" text NOT NULL, "expires_at" timestamp with time zone NOT NULL ); --> statement-breakpoint @@ -26,5 +23,6 @@ CREATE TABLE "identity_links" ( --> statement-breakpoint ALTER TABLE "identity_links" ADD CONSTRAINT "identity_links_credential_id_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."credentials"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint CREATE INDEX "identity_link_challenges_expiry_idx" ON "identity_link_challenges" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "identity_link_challenges_user_idx" ON "identity_link_challenges" USING btree ("user_id","provider");--> statement-breakpoint CREATE UNIQUE INDEX "identity_links_identity_idx" ON "identity_links" USING btree ("provider","realm","subject");--> statement-breakpoint CREATE UNIQUE INDEX "identity_links_user_realm_idx" ON "identity_links" USING btree ("user_id","provider","realm"); \ No newline at end of file diff --git a/server/drizzle/meta/0052_snapshot.json b/server/drizzle/meta/0052_snapshot.json index c6428b47b..81b3e9634 100644 --- a/server/drizzle/meta/0052_snapshot.json +++ b/server/drizzle/meta/0052_snapshot.json @@ -1,5 +1,5 @@ { - "id": "d5ad8938-e2e1-4eea-8e82-9a84125edd1d", + "id": "54122071-85f2-4f2b-a125-703090a744a2", "prevId": "b4bb328d-1308-403f-a2ac-f8a291aaabc5", "version": "7", "dialect": "postgresql", @@ -8927,30 +8927,12 @@ "primaryKey": false, "notNull": true }, - "realm": { - "name": "realm", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "subject": { - "name": "subject", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, - "handle": { - "name": "handle", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "confirmed_user_id": { - "name": "confirmed_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, "expires_at": { "name": "expires_at", "type": "timestamp with time zone", @@ -8973,6 +8955,27 @@ "concurrently": false, "method": "btree", "with": {} + }, + "identity_link_challenges_user_idx": { + "name": "identity_link_challenges_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": {}, diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 48c0e6269..3e53b2e19 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -369,7 +369,7 @@ { "idx": 52, "version": "7", - "when": 1791470171980, + "when": 1791472331083, "tag": "0052_identity_links", "breakpoints": true } diff --git a/server/src/app.ts b/server/src/app.ts index 3a3b21544..4dbf8be53 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -685,14 +685,7 @@ export function createApp( * A person's own linked accounts at outside providers (Slack, GitHub). Theirs alone: there is no * administrator view and no linking on somebody else's behalf. */ - const trustedAppOrigins = [ - ...(config.auth?.trustedOrigins ?? []), - ...(config.appUrl ? [config.appUrl] : []), - ]; - app.route( - "/api/identity", - identityRoutes(requireUser, identity, trustedAppOrigins), - ); + app.route("/api/identity", identityRoutes(requireUser, identity)); app.get("/api/settings/instructions", requireUser, async (context) => { if (!userInstructions) { @@ -773,7 +766,10 @@ export function createApp( createLearningRoutes( learning, requireUser, - trustedAppOrigins, + [ + ...(config.auth?.trustedOrigins ?? []), + ...(config.appUrl ? [config.appUrl] : []), + ], auditStore, ), ); diff --git a/server/src/db/schema/identity.ts b/server/src/db/schema/identity.ts index 12405789e..6d58e9521 100644 --- a/server/src/db/schema/identity.ts +++ b/server/src/db/schema/identity.ts @@ -61,17 +61,21 @@ export const identityLinks = pgTable( ], ); -/** Neither possession of the token nor the signed-in confirmation alone establishes a link. */ +/** + * One-time link codes OpenBot issues to a signed-in person. The link is made when the code arrives + * from that person's chat account, so the code only ever links to the user it was issued to. Only + * the sha256 of the code is stored. `user_id` is not a foreign key, for the same reason as above. + */ export const identityLinkChallenges = pgTable( "identity_link_challenges", { tokenHash: text("token_hash").primaryKey(), provider: text("provider").notNull(), - realm: text("realm").notNull(), - subject: text("subject").notNull(), - handle: text("handle"), - confirmedUserId: text("confirmed_user_id"), + userId: text("user_id").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), }, - (table) => [index("identity_link_challenges_expiry_idx").on(table.expiresAt)], + (table) => [ + index("identity_link_challenges_expiry_idx").on(table.expiresAt), + index("identity_link_challenges_user_idx").on(table.userId, table.provider), + ], ); diff --git a/server/src/identity/providers.ts b/server/src/identity/providers.ts index 628a5c1ee..761d96c25 100644 --- a/server/src/identity/providers.ts +++ b/server/src/identity/providers.ts @@ -10,8 +10,8 @@ type ProviderSpec = { title: string; /** The proofs this provider's links may be made with. Anything else is refused by the store. */ methods: readonly LinkMethod[]; - /** For challenge providers: what the person does at the provider to finish. */ - completionHint?: (token: string) => string; + /** For challenge providers: what the person sends from their account there, carrying the code. */ + instruction?: (code: string) => string; }; export const PROVIDERS: Readonly> = @@ -19,8 +19,8 @@ export const PROVIDERS: Readonly> = slack: { title: "Slack", methods: ["challenge"], - completionHint: (token) => - `From the Slack account that asked for this link, mention the same bot with: /link ${token}`, + instruction: (code) => + `Send this message to the OpenBot bot in Slack: link ${code}`, }, github: { title: "GitHub", methods: ["oauth"] }, }); @@ -36,8 +36,9 @@ export function acceptsMethod(provider: IdentityProvider, method: LinkMethod) { } /** - * The Slack branch matched a sender on connection, installation and workspace. The realm keeps - * exactly that, each part escaped so a colon inside one part cannot imitate the separator. + * A Slack user id is only unique within one workspace reached through one installation of one + * connection, so the realm is those three parts, each escaped so a colon inside one part cannot + * imitate the separator. */ export function slackRealm(parts: { connectionId: string; diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index 07ac64b37..a11ec7a15 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -1,16 +1,10 @@ import { Hono, type MiddlewareHandler } from "hono"; -import { z } from "zod"; import type { AppVariables } from "../auth/guards"; import { PROVIDERS } from "./providers"; import type { IdentityStore } from "./store"; -import { IdentityLinkError } from "./types"; - -const tokenBody = z.strictObject({ - token: z.string().regex(/^[A-Za-z0-9_-]{43}$/), -}); /** - * A person's own linked accounts, and the browser half of a chat-started link. + * A person's own linked accounts. * * Every route is the asker's own: there is no administrator view of somebody else's links and no * way to link an account on another person's behalf. What leaves here is what the settings page @@ -18,11 +12,7 @@ const tokenBody = z.strictObject({ */ export function identityRoutes( requireUser: MiddlewareHandler<{ Variables: AppVariables }>, - store?: Pick< - IdentityStore, - "identitiesFor" | "unlink" | "peekChallenge" | "confirmChallenge" - >, - trustedOrigins: readonly string[] = [], + store?: Pick, ) { const app = new Hono<{ Variables: AppVariables }>(); app.use("*", async (context, next) => { @@ -62,65 +52,5 @@ export function identityRoutes( return context.body(null, 204); }); - app.post("/challenges/peek", async (context) => { - const parsed = tokenBody.safeParse( - await context.req.json().catch(() => null), - ); - if (!parsed.success) return context.json({ error: "Invalid link." }, 400); - const challenge = await live.peekChallenge(parsed.data.token); - if (!challenge) - return context.json( - { error: "This link expired or was already used. Ask for a new one." }, - 404, - ); - return context.json({ - challenge: { - provider: challenge.provider, - title: PROVIDERS[challenge.provider].title, - handle: challenge.handle, - }, - }); - }); - - app.post("/challenges/confirm", async (context) => { - // Confirming binds a chat identity to the signed-in person, so a page on another site must not - // be able to trigger it with the person's cookies. - const origin = context.req.header("origin"); - if ( - origin - ? !new Set([new URL(context.req.url).origin, ...trustedOrigins]).has( - origin, - ) - : context.req.header("sec-fetch-site") === "cross-site" - ) { - return context.json( - { error: "Confirm this link from the OpenBot app." }, - 403, - ); - } - const parsed = tokenBody.safeParse( - await context.req.json().catch(() => null), - ); - if (!parsed.success) return context.json({ error: "Invalid link." }, 400); - const challenge = await live.peekChallenge(parsed.data.token); - try { - await live.confirmChallenge(parsed.data.token, context.var.actor.id); - } catch (error) { - if (!(error instanceof IdentityLinkError)) throw error; - return context.json( - { - error: - "This link expired, was already used, or was confirmed by a different account. Ask for a new one.", - }, - 409, - ); - } - const hint = challenge - ? (PROVIDERS[challenge.provider].completionHint?.(parsed.data.token) ?? - null) - : null; - return context.json({ confirmation: { hint } }); - }); - return app; } diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index ac7b2418b..3f62e8e86 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -1,9 +1,9 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; +import { createHash, randomUUID } from "node:crypto"; +import { and, eq, gt, lt, sql } from "drizzle-orm"; import { CredentialRefusedError, type CredentialStore } from "../credentials"; import type { Database } from "../db/client"; import { identityLinkChallenges, identityLinks } from "../db/schema"; -import { acceptsMethod, parseIdentity } from "./providers"; +import { acceptsMethod, isIdentityProvider, parseIdentity } from "./providers"; import { type Identity, IdentityConflictError, @@ -167,9 +167,13 @@ export async function writeLink( } } -function tokenHash(token: string) { - if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new IdentityLinkError(); - return createHash("sha256").update(token).digest("hex"); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Refuses anything that is not a code OpenBot could have issued before it reaches a query. */ +function codeHash(code: string) { + if (typeof code !== "string" || !UUID.test(code)) + throw new IdentityLinkError(); + return createHash("sha256").update(code.toLowerCase()).digest("hex"); } export function createIdentityStore(database: Database, credentials: Revoke) { @@ -245,108 +249,75 @@ export function createIdentityStore(database: Database, credentials: Revoke) { }); }, - async beginChallenge( - value: Identity, - handle: string | null = null, - ): Promise<{ token: string; expiresAt: Date }> { - const identity = parseIdentity(value); - if (!acceptsMethod(identity.provider, "challenge")) + /** + * A one-time code for the signed-in `userId`. The link is made when the code arrives from the + * person's account at `provider` (redeemChallenge), and always links to the user it was issued + * to, so a code started by somebody else can never attach their account to this person. + */ + async issueChallenge( + userId: string, + provider: IdentityProvider, + ): Promise<{ code: string; expiresAt: Date }> { + if (!userId.trim()) throw new IdentityInputError(); + if ( + !isIdentityProvider(provider) || + !acceptsMethod(provider, "challenge") + ) throw new IdentityInputError( "This provider does not accept that kind of proof.", ); - const token = randomBytes(32).toString("base64url"); - // No sweep exists, so chat-started links that were never opened would otherwise accumulate. - await database - .delete(identityLinkChallenges) - .where( - lt( - identityLinkChallenges.expiresAt, - sql`clock_timestamp() - interval '1 day'`, - ), - ); - const [challenge] = await database - .insert(identityLinkChallenges) - .values({ - tokenHash: tokenHash(token), - provider: identity.provider, - realm: identity.realm, - subject: identity.subject, - handle: handle?.slice(0, 256) ?? null, - expiresAt: sql`clock_timestamp() + interval '10 minutes'`, - }) - .returning({ expiresAt: identityLinkChallenges.expiresAt }); - if (!challenge) throw new IdentityLinkError(); - return { token, expiresAt: challenge.expiresAt }; - }, - - async peekChallenge( - token: string, - ): Promise<{ provider: IdentityProvider; handle: string | null } | null> { - let hash: string; - try { - hash = tokenHash(token); - } catch { - return null; - } - const [challenge] = await database - .select({ - provider: identityLinkChallenges.provider, - handle: identityLinkChallenges.handle, - }) - .from(identityLinkChallenges) - .where( - and( - eq(identityLinkChallenges.tokenHash, hash), - gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), - ), - ); - return challenge - ? { - provider: challenge.provider as IdentityProvider, - handle: challenge.handle, - } - : null; - }, - - /** The route authenticates this user; no link exists until completeChallenge. */ - async confirmChallenge(token: string, userId: string): Promise { - if (!userId.trim()) throw new IdentityLinkError(); - const [challenge] = await database - .update(identityLinkChallenges) - .set({ confirmedUserId: userId }) - .where( - and( - eq(identityLinkChallenges.tokenHash, tokenHash(token)), - gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), - or( - isNull(identityLinkChallenges.confirmedUserId), - eq(identityLinkChallenges.confirmedUserId, userId), + const code = randomUUID(); + return database.transaction(async (transaction) => { + // One live code per person per provider: a new one replaces any earlier one. + await transaction + .delete(identityLinkChallenges) + .where( + and( + eq(identityLinkChallenges.userId, userId), + eq(identityLinkChallenges.provider, provider), ), - ), - ) - .returning({ tokenHash: identityLinkChallenges.tokenHash }); - if (!challenge) throw new IdentityLinkError(); + ); + // No sweep exists, so codes that were never sent would otherwise accumulate. + await transaction + .delete(identityLinkChallenges) + .where( + lt( + identityLinkChallenges.expiresAt, + sql`clock_timestamp() - interval '1 day'`, + ), + ); + const [challenge] = await transaction + .insert(identityLinkChallenges) + .values({ + tokenHash: codeHash(code), + provider, + userId, + expiresAt: sql`clock_timestamp() + interval '10 minutes'`, + }) + .returning({ expiresAt: identityLinkChallenges.expiresAt }); + if (!challenge) throw new IdentityLinkError(); + return { code, expiresAt: challenge.expiresAt }; + }); }, - /** Consumption and link creation in one transaction, so competing challenges serialize. */ - async completeChallenge( - token: string, + /** + * Link `value`, the chat account the code arrived from, to the user the code was issued to. + * Consumption and link creation share one transaction: a refused link leaves the code unused. + */ + async redeemChallenge( + code: string, value: Identity, + handle: string | null = null, ): Promise { + const hash = codeHash(code); const identity = parseIdentity(value); - const hash = tokenHash(token); return database.transaction(async (transaction) => { const [challenge] = await transaction .select() .from(identityLinkChallenges) .where(eq(identityLinkChallenges.tokenHash, hash)) .for("update"); - if ( - !challenge?.confirmedUserId || - challenge.provider !== identity.provider || - challenge.realm !== identity.realm || - challenge.subject !== identity.subject - ) + if (!challenge || challenge.provider !== identity.provider) throw new IdentityLinkError(); // The real clock after the row lock, not the transaction's start time. const [consumed] = await transaction @@ -357,15 +328,12 @@ export function createIdentityStore(database: Database, credentials: Revoke) { gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), ), ) - .returning({ tokenHash: identityLinkChallenges.tokenHash }); + .returning({ userId: identityLinkChallenges.userId }); if (!consumed) throw new IdentityLinkError(); - return writeLink( - transaction, - credentials, - identity, - challenge.confirmedUserId, - { method: "challenge", handle: challenge.handle }, - ); + return writeLink(transaction, credentials, identity, consumed.userId, { + method: "challenge", + handle: handle?.slice(0, 256) ?? null, + }); }); }, }; diff --git a/server/src/identity/types.ts b/server/src/identity/types.ts index 043e78046..a91b3a5b2 100644 --- a/server/src/identity/types.ts +++ b/server/src/identity/types.ts @@ -22,7 +22,7 @@ export type IdentityLink = Identity & { /** No caller-controlled identifiers, tokens, or database parameters appear in these errors. */ export class IdentityLinkError extends Error { - constructor(message = "Link challenge is invalid, expired, or unconfirmed.") { + constructor(message = "Link code is invalid, expired, or already used.") { super(message); this.name = "IdentityLinkError"; } diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts index bb14b3e8d..e13c5b780 100644 --- a/server/tests/identity-challenges.integration.test.ts +++ b/server/tests/identity-challenges.integration.test.ts @@ -1,184 +1,230 @@ import { afterAll, expect, test } from "bun:test"; import { createHash, randomUUID } from "node:crypto"; -import { eq, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import { createCredentialStore } from "../src/credentials"; import { createDatabase } from "../src/db/client"; import { identityLinkChallenges, identityLinks } from "../src/db/schema"; import { createIdentityStore } from "../src/identity/store"; import { type Identity, + IdentityConflictError, IdentityInputError, IdentityLinkError, + type IdentityProvider, } from "../src/identity/types"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const store = createIdentityStore(database, createCredentialStore(database)); const realm = `test-${randomUUID()}`; +const users: string[] = []; afterAll(async () => { - await database - .delete(identityLinkChallenges) - .where(eq(identityLinkChallenges.realm, realm)); + if (users.length) + await database + .delete(identityLinkChallenges) + .where(inArray(identityLinkChallenges.userId, users)); await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); await database.$client.close(); }); +const user = () => { + const id = `user-${randomUUID()}`; + users.push(id); + return id; +}; const slack = (subject = randomUUID()): Identity => ({ provider: "slack", realm, subject, }); +const hashOf = (code: string) => + createHash("sha256").update(code).digest("hex"); +const rowFor = async (code: string) => + ( + await database + .select() + .from(identityLinkChallenges) + .where(eq(identityLinkChallenges.tokenHash, hashOf(code))) + )[0]; +const expire = (code: string, by = "1 second") => + database + .update(identityLinkChallenges) + .set({ expiresAt: sql`clock_timestamp() - ${by}::interval` }) + .where(eq(identityLinkChallenges.tokenHash, hashOf(code))); +const dbNow = async () => { + const result = await database.execute<{ now: string | Date }>( + sql`select clock_timestamp() as now`, + ); + const rows = Array.isArray(result) + ? result + : (result as { rows: unknown[] }).rows; + return new Date((rows[0] as { now: string | Date }).now).getTime(); +}; -test("extra identity properties cannot pre-confirm an application account", async () => { - const sender = { ...slack(), confirmedUserId: "forged-user" }; - const { token } = await store.beginChallenge(sender); - await expect(store.completeChallenge(token, sender)).rejects.toThrow( - IdentityLinkError, +test("a code is a UUID, only its hash is stored, and it lasts ten minutes", async () => { + const owner = user(); + const before = await dbNow(); + const { code, expiresAt } = await store.issueChallenge(owner, "slack"); + const after = await dbNow(); + expect(code).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); - expect(await store.linkedUser(sender)).toBeNull(); + expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + 600_000); + expect(expiresAt.getTime()).toBeLessThanOrEqual(after + 600_000); + const saved = await rowFor(code); + expect(saved).toEqual({ + tokenHash: hashOf(code), + provider: "slack", + userId: owner, + expiresAt, + }); + expect(JSON.stringify(saved)).not.toContain(code); }); -test("a provider that does not accept challenges cannot begin one", async () => { +test("a provider that does not accept challenges cannot issue a code", async () => { + await expect(store.issueChallenge(user(), "github")).rejects.toThrow( + IdentityInputError, + ); await expect( - store.beginChallenge({ provider: "github", realm, subject: "1" }), + store.issueChallenge(user(), "myspace" as IdentityProvider), ).rejects.toThrow(IdentityInputError); + await expect(store.issueChallenge(" ", "slack")).rejects.toThrow( + IdentityInputError, + ); }); -test("linking needs both confirmations, stores only a hash, and consumes once", async () => { +test("redeeming links the chat account to the person the code was issued to", async () => { + // The fixation regression: whoever sends the code, the link belongs to the issuer. + const owner = user(); const sender = slack(); - const before = Date.now(); - const challenge = await store.beginChallenge(sender, "dana"); - expect(Buffer.from(challenge.token, "base64url").length).toBe(32); - expect(challenge.expiresAt.getTime()).toBeGreaterThanOrEqual( - before + 599_000, - ); - expect(challenge.expiresAt.getTime()).toBeLessThanOrEqual( - Date.now() + 600_000, - ); - const [saved] = await database - .select() - .from(identityLinkChallenges) - .where( - eq( - identityLinkChallenges.tokenHash, - createHash("sha256").update(challenge.token).digest("hex"), - ), - ); - expect(saved).toBeDefined(); - expect(JSON.stringify(saved)).not.toContain(challenge.token); - expect(await store.peekChallenge(challenge.token)).toEqual({ - provider: "slack", - handle: "dana", - }); - await expect( - store.completeChallenge(challenge.token, sender), - ).rejects.toThrow(IdentityLinkError); - await store.confirmChallenge(challenge.token, "user-a"); - await store.confirmChallenge(challenge.token, "user-a"); - expect(await store.linkedUser(sender)).toBeNull(); - const link = await store.completeChallenge(challenge.token, sender); + const { code } = await store.issueChallenge(owner, "slack"); + const link = await store.redeemChallenge(code, sender, "dana"); + expect(link.userId).toBe(owner); expect(link.verifiedBy).toBe("challenge"); expect(link.handle).toBe("dana"); - expect((await store.linkedUser(sender))?.userId).toBe("user-a"); - await expect( - store.completeChallenge(challenge.token, sender), - ).rejects.toThrow(IdentityLinkError); - await expect( - store.confirmChallenge(challenge.token, "user-a"), - ).rejects.toThrow(IdentityLinkError); - expect(await store.peekChallenge(challenge.token)).toBeNull(); + expect(await store.linkedUser(sender)).toEqual({ + userId: owner, + status: "active", + }); + expect(await rowFor(code)).toBeUndefined(); }); -test("confirmation cannot change the signed-in application user", async () => { - const sender = slack(); - const { token } = await store.beginChallenge(sender); - const results = await Promise.allSettled([ - store.confirmChallenge(token, "user-a"), - store.confirmChallenge(token, "user-b"), - ]); - expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1); - await store.completeChallenge(token, sender); - expect(["user-a", "user-b"]).toContain( - (await store.linkedUser(sender))?.userId, +test("extra properties on the chat identity cannot redirect the link", async () => { + const owner = user(); + const sender = { ...slack(), userId: "forged-user" }; + const { code } = await store.issueChallenge(owner, "slack"); + expect((await store.redeemChallenge(code, sender)).userId).toBe(owner); +}); + +test("a code is consumed once", async () => { + const { code } = await store.issueChallenge(user(), "slack"); + await store.redeemChallenge(code, slack()); + const other = slack(); + await expect(store.redeemChallenge(code, other)).rejects.toThrow( + IdentityLinkError, ); + expect(await store.linkedUser(other)).toBeNull(); }); -test.each(["provider", "realm", "subject"] as const)( - "a challenge refuses a different %s without consuming it", - async (field) => { - const sender = slack(); - const { token } = await store.beginChallenge(sender); - await store.confirmChallenge(token, "user-a"); - const other = - field === "provider" - ? { ...sender, provider: "github" as const } - : { ...sender, [field]: "other" }; - await expect(store.completeChallenge(token, other)).rejects.toThrow(); - await store.completeChallenge(token, sender); - expect((await store.linkedUser(sender))?.userId).toBe("user-a"); - }, -); - -const hashOf = (token: string) => - createHash("sha256").update(token).digest("hex"); -const expire = (token: string) => - database - .update(identityLinkChallenges) - .set({ expiresAt: sql`clock_timestamp() - interval '1 second'` }) - .where(eq(identityLinkChallenges.tokenHash, hashOf(token))); +test("a code from another provider is refused without consuming it", async () => { + const owner = user(); + const { code } = await store.issueChallenge(owner, "slack"); + const wrong: Identity = { provider: "github", realm, subject: randomUUID() }; + await expect(store.redeemChallenge(code, wrong)).rejects.toThrow( + IdentityLinkError, + ); + expect(await store.linkedUser(wrong)).toBeNull(); + expect(await rowFor(code)).toBeDefined(); + expect((await store.redeemChallenge(code, slack())).userId).toBe(owner); +}); -test("an expired challenge can be neither peeked, confirmed nor completed", async () => { +test("an expired code is refused", async () => { + const { code } = await store.issueChallenge(user(), "slack"); + await expire(code); const sender = slack(); - const confirmed = await store.beginChallenge(sender); - await store.confirmChallenge(confirmed.token, "user-a"); - await expire(confirmed.token); - expect(await store.peekChallenge(confirmed.token)).toBeNull(); - await expect( - store.completeChallenge(confirmed.token, sender), - ).rejects.toThrow(IdentityLinkError); + await expect(store.redeemChallenge(code, sender)).rejects.toThrow( + IdentityLinkError, + ); + expect(await store.linkedUser(sender)).toBeNull(); +}); - const unconfirmed = await store.beginChallenge(slack()); - await expire(unconfirmed.token); - expect(await store.peekChallenge(unconfirmed.token)).toBeNull(); - await expect( - store.confirmChallenge(unconfirmed.token, "user-a"), - ).rejects.toThrow(IdentityLinkError); +test.each([ + "", + "short", + "link 123e4567-e89b-42d3-a456-426614174000", + "123e4567e89b42d3a456426614174000", + "' or 1=1 --", +])("a malformed code %p is refused", async (code) => { + await expect(store.redeemChallenge(code, slack())).rejects.toThrow( + IdentityLinkError, + ); }); -test("beginning a challenge clears challenges that expired over a day ago", async () => { - const stale = `stale-${randomUUID()}`; - await database.insert(identityLinkChallenges).values({ - tokenHash: stale, - provider: "slack", - realm, - subject: randomUUID(), - expiresAt: sql`clock_timestamp() - interval '2 days'`, - }); - await store.beginChallenge(slack()); - const rows = await database - .select() - .from(identityLinkChallenges) - .where(eq(identityLinkChallenges.tokenHash, stale)); - expect(rows).toEqual([]); +test("an unknown but well-formed code is refused", async () => { + await expect(store.redeemChallenge(randomUUID(), slack())).rejects.toThrow( + IdentityLinkError, + ); }); -test("a malformed token is refused before any query", async () => { - await expect(store.confirmChallenge("short", "user-a")).rejects.toThrow( +test("issuing again invalidates the person's previous code for that provider", async () => { + const owner = user(); + const first = await store.issueChallenge(owner, "slack"); + const second = await store.issueChallenge(owner, "slack"); + expect(await rowFor(first.code)).toBeUndefined(); + await expect(store.redeemChallenge(first.code, slack())).rejects.toThrow( IdentityLinkError, ); - expect(await store.peekChallenge("short")).toBeNull(); + expect((await store.redeemChallenge(second.code, slack())).userId).toBe( + owner, + ); +}); + +test("issuing for one person leaves other people's codes alone", async () => { + const mine = await store.issueChallenge(user(), "slack"); + await store.issueChallenge(user(), "slack"); + expect(await rowFor(mine.code)).toBeDefined(); +}); + +test("issuing sweeps codes expired over a day ago and keeps recently expired ones", async () => { + const staleOwner = user(); + const recentOwner = user(); + const stale = await store.issueChallenge(staleOwner, "slack"); + const recent = await store.issueChallenge(recentOwner, "slack"); + await expire(stale.code, "2 days"); + await expire(recent.code, "1 hour"); + await store.issueChallenge(user(), "slack"); + expect(await rowFor(stale.code)).toBeUndefined(); + expect(await rowFor(recent.code)).toBeDefined(); +}); + +test("a chat account linked to somebody else is refused and the code stays unused", async () => { + const sender = slack(); + const first = user(); + const second = user(); + const one = await store.issueChallenge(first, "slack"); + await store.redeemChallenge(one.code, sender); + const two = await store.issueChallenge(second, "slack"); + await expect(store.redeemChallenge(two.code, sender)).rejects.toThrow( + IdentityConflictError, + ); + expect((await store.linkedUser(sender))?.userId).toBe(first); + expect(await rowFor(two.code)).toBeDefined(); + expect((await store.redeemChallenge(two.code, slack())).userId).toBe(second); }); -test("completing a second challenge in the same realm replaces the person's older link", async () => { +test("redeeming a second code in the same realm replaces the person's older link", async () => { + const owner = user(); const first = slack(); - const one = await store.beginChallenge(first); - await store.confirmChallenge(one.token, "user-z"); - await store.completeChallenge(one.token, first); const second = slack(); - const two = await store.beginChallenge(second); - await store.confirmChallenge(two.token, "user-z"); - await store.completeChallenge(two.token, second); + await store.redeemChallenge( + (await store.issueChallenge(owner, "slack")).code, + first, + ); + await store.redeemChallenge( + (await store.issueChallenge(owner, "slack")).code, + second, + ); expect(await store.linkedUser(first)).toBeNull(); - expect((await store.linkedUser(second))?.userId).toBe("user-z"); + expect((await store.linkedUser(second))?.userId).toBe(owner); }); diff --git a/server/tests/identity-providers.test.ts b/server/tests/identity-providers.test.ts index 1f82f65cd..129297415 100644 --- a/server/tests/identity-providers.test.ts +++ b/server/tests/identity-providers.test.ts @@ -9,7 +9,7 @@ import { } from "../src/identity/providers"; import { IdentityInputError } from "../src/identity/types"; -test("slackRealm keeps the three parts the Slack branch matched on", () => { +test("slackRealm keeps connection, installation and workspace", () => { expect( slackRealm({ connectionId: "openbot", @@ -50,8 +50,12 @@ test("each provider declares the proofs it accepts", () => { expect(acceptsMethod("github", "challenge")).toBe(false); }); -test("Slack tells the person how to finish from Slack", () => { - expect(PROVIDERS.slack.completionHint?.("tok")).toContain("/link tok"); +test("Slack tells the person to send the code to the bot", () => { + const code = "123e4567-e89b-42d3-a456-426614174000"; + expect(PROVIDERS.slack.instruction?.(code)).toBe( + `Send this message to the OpenBot bot in Slack: link ${code}`, + ); + expect(PROVIDERS.github.instruction).toBeUndefined(); }); test("isIdentityProvider", () => { diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 303b7ca8a..d10f0e1e9 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import type { AppVariables } from "../src/auth/guards"; import { identityRoutes } from "../src/identity/routes"; -import { type IdentityLink, IdentityLinkError } from "../src/identity/types"; +import type { IdentityLink } from "../src/identity/types"; const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( context, @@ -15,7 +15,6 @@ const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( } as never); await next(); }; -const token = "a".repeat(43); const link: IdentityLink = { id: "link-1", provider: "github", @@ -30,34 +29,18 @@ const link: IdentityLink = { updatedAt: new Date("2026-10-08T00:00:00Z"), }; -function fixture(trustedOrigins: readonly string[] = []) { +function fixture() { const calls: string[] = []; - const app = identityRoutes( - signedIn, - { - identitiesFor: async (userId) => (userId === "person" ? [link] : []), - unlink: async (userId, id) => { - calls.push(`unlink:${userId}:${id}`); - return userId === "person" && id === "link-1"; - }, - peekChallenge: async (t) => - t === token ? { provider: "slack", handle: "dana" } : null, - confirmChallenge: async (t, userId) => { - calls.push(`confirm:${userId}`); - if (t !== token) throw new IdentityLinkError(); - }, + const app = identityRoutes(signedIn, { + identitiesFor: async (userId) => (userId === "person" ? [link] : []), + unlink: async (userId, id) => { + calls.push(`unlink:${userId}:${id}`); + return userId === "person" && id === "link-1"; }, - trustedOrigins, - ); + }); return { app, calls }; } -const post = (body: unknown) => ({ - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), -}); - test("links lists the asker's links without subject, realm or credential", async () => { const response = await fixture().app.request("/links"); expect(response.status).toBe(200); @@ -90,128 +73,18 @@ test("DELETE of another person's link is 404", async () => { ).toBe(404); }); -test("peek names the provider of a live challenge", async () => { - const response = await fixture().app.request( - "/challenges/peek", - post({ token }), - ); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - challenge: { provider: "slack", title: "Slack", handle: "dana" }, - }); -}); - -test("peek of an expired token is 404", async () => { - const response = await fixture().app.request( - "/challenges/peek", - post({ token: "b".repeat(43) }), - ); - expect(response.status).toBe(404); -}); - -test("confirm records the signed-in user and returns the provider's hint", async () => { - const f = fixture(); - const response = await f.app.request("/challenges/confirm", post({ token })); - expect(response.status).toBe(200); - const body = (await response.json()) as { confirmation: { hint: string } }; - expect(body.confirmation.hint).toContain(`/link ${token}`); - expect(f.calls).toEqual(["confirm:person"]); -}); - -test("confirm of a bad token is 409 with a sentence, and a malformed body is 400", async () => { +test("the chat-started confirmation routes are gone", async () => { const f = fixture(); - const bad = await f.app.request( - "/challenges/confirm", - post({ token: "c".repeat(43) }), - ); - expect(bad.status).toBe(409); - expect(((await bad.json()) as { error: string }).error).toContain("expired"); - expect((await f.app.request("/challenges/confirm", post({}))).status).toBe( - 400, - ); -}); - -test("confirm surfaces an unexpected failure instead of answering 409", async () => { - const app = identityRoutes(signedIn, { - identitiesFor: async () => [], - unlink: async () => false, - peekChallenge: async () => ({ provider: "slack", handle: "dana" }), - confirmChallenge: async () => { - throw new Error("db down"); - }, - }); - const response = await app.request("/challenges/confirm", post({ token })); - expect(response.status).not.toBe(409); - expect(response.status).toBe(500); -}); - -test("confirm refuses cross-site requests without confirming", async () => { - for (const headers of [ - { "sec-fetch-site": "cross-site" }, - { origin: "https://evil.example" }, - ]) { - const f = fixture(); - const response = await f.app.request( - "http://openbot.test/challenges/confirm", - { - ...post({ token }), - headers: { "content-type": "application/json", ...headers }, - }, - ); - expect(response.status).toBe(403); - expect(f.calls).toEqual([]); + for (const path of ["/challenges/peek", "/challenges/confirm"]) { + const response = await f.app.request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: "a".repeat(43) }), + }); + expect(response.status).toBe(404); } }); -test("confirm accepts same-origin requests", async () => { - const f = fixture(); - const response = await f.app.request( - "http://openbot.test/challenges/confirm", - { - ...post({ token }), - headers: { - "content-type": "application/json", - origin: "http://openbot.test", - "sec-fetch-site": "same-origin", - }, - }, - ); - expect(response.status).toBe(200); - expect(f.calls).toEqual(["confirm:person"]); -}); - -test("confirm accepts a trusted app origin behind a proxy", async () => { - const f = fixture(["https://openbot.example"]); - const response = await f.app.request( - "http://openbot.test/challenges/confirm", - { - ...post({ token }), - headers: { - "content-type": "application/json", - origin: "https://openbot.example", - }, - }, - ); - expect(response.status).toBe(200); - expect(f.calls).toEqual(["confirm:person"]); -}); - -test("confirm refuses an untrusted origin that differs from the request", async () => { - const f = fixture(); - const response = await f.app.request( - "http://openbot.test/challenges/confirm", - { - ...post({ token }), - headers: { - "content-type": "application/json", - origin: "https://openbot.example", - }, - }, - ); - expect(response.status).toBe(403); - expect(f.calls).toEqual([]); -}); - test("responses are not cached", async () => { const response = await fixture().app.request("/links"); expect(response.headers.get("cache-control")).toBe("no-store"); From 285448e42ad7c8495a238cf5fb49d8fb2b765ed9 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:14:20 -0300 Subject: [PATCH 026/101] refactor(app): remove the chat-started link confirmation page The server no longer confirms chat-started links (codes are issued to the signed-in person instead), so /settings/link, its link-state helper and the challenge query and confirm mutation go. The route tree is regenerated and matches main's. Call sites of removed symbols: challengeQueryOptions, LinkChallenge, identityKeys.challenge, confirmChallengeMutationOptions and the link-state exports were used only by settings/link.tsx and link-state.test.ts, both deleted. --- app/src/lib/identity/link-state.test.ts | 76 -------------------- app/src/lib/identity/link-state.ts | 49 ------------- app/src/lib/identity/mutations.ts | 15 ---- app/src/lib/identity/queries.ts | 24 ------- app/src/routeTree.gen.ts | 21 ------ app/src/routes/_authed/settings/link.tsx | 88 ------------------------ 6 files changed, 273 deletions(-) delete mode 100644 app/src/lib/identity/link-state.test.ts delete mode 100644 app/src/lib/identity/link-state.ts delete mode 100644 app/src/routes/_authed/settings/link.tsx diff --git a/app/src/lib/identity/link-state.test.ts b/app/src/lib/identity/link-state.test.ts deleted file mode 100644 index 78cbc2ceb..000000000 --- a/app/src/lib/identity/link-state.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { expect, test } from "bun:test"; -import { confirmLinkState } from "./link-state"; - -const idle = { status: "idle" as const }; -const token = "a".repeat(43); - -test("no or malformed token is invalid", () => { - expect( - confirmLinkState({ - token: undefined, - peek: { status: "pending" }, - confirm: idle, - }).kind, - ).toBe("invalid"); - expect( - confirmLinkState({ - token: "short", - peek: { status: "pending" }, - confirm: idle, - }).kind, - ).toBe("invalid"); -}); - -test("peek pending is loading; peek error is expired", () => { - expect( - confirmLinkState({ token, peek: { status: "pending" }, confirm: idle }) - .kind, - ).toBe("loading"); - expect( - confirmLinkState({ token, peek: { status: "error" }, confirm: idle }).kind, - ).toBe("expired"); -}); - -test("a live challenge is ready and names its provider", () => { - expect( - confirmLinkState({ - token, - peek: { status: "success", data: { title: "Slack" } }, - confirm: idle, - }), - ).toEqual({ kind: "ready", title: "Slack" }); -}); - -test("confirm states", () => { - const peek = { status: "success" as const, data: { title: "Slack" } }; - expect( - confirmLinkState({ token, peek, confirm: { status: "pending" } }).kind, - ).toBe("confirming"); - expect( - confirmLinkState({ token, peek, confirm: { status: "error" } }).kind, - ).toBe("failed"); - expect( - confirmLinkState({ - token, - peek, - confirm: { status: "success", hint: "do X" }, - }), - ).toEqual({ kind: "done", title: "Slack", hint: "do X" }); -}); - -test("a confirmed link stays done when the peek later errors", () => { - expect( - confirmLinkState({ - token, - peek: { status: "error" }, - confirm: { status: "success", hint: "do X" }, - }), - ).toEqual({ kind: "done", title: undefined, hint: "do X" }); - expect( - confirmLinkState({ - token, - peek: { status: "error", data: { title: "Slack" } }, - confirm: { status: "success" }, - }), - ).toEqual({ kind: "done", title: "Slack", hint: null }); -}); diff --git a/app/src/lib/identity/link-state.ts b/app/src/lib/identity/link-state.ts deleted file mode 100644 index fbaadc891..000000000 --- a/app/src/lib/identity/link-state.ts +++ /dev/null @@ -1,49 +0,0 @@ -type Peek = { - status: "pending" | "error" | "success"; - data?: { title: string }; -}; -type Confirm = { - status: "idle" | "pending" | "error" | "success"; - hint?: string | null; -}; -export type ConfirmLinkState = { - kind: - | "invalid" - | "loading" - | "expired" - | "ready" - | "confirming" - | "failed" - | "done"; - title?: string; - hint?: string | null; -}; - -/** What the confirm page draws, from the token and its two requests. Pure, so it is tested alone. */ -export function confirmLinkState(input: { - token: string | undefined; - peek: Peek; - confirm: Confirm; -}): ConfirmLinkState { - if (!input.token || !/^[A-Za-z0-9_-]{43}$/.test(input.token)) - return { kind: "invalid" }; - // A confirmed link stays done: the challenge is spent, so a later peek refetch would only 404. - if (input.confirm.status === "success") - return { - kind: "done", - title: input.peek.data?.title, - hint: input.confirm.hint ?? null, - }; - if (input.peek.status === "pending") return { kind: "loading" }; - if (input.peek.status === "error" || !input.peek.data) - return { kind: "expired" }; - const title = input.peek.data.title; - switch (input.confirm.status) { - case "pending": - return { kind: "confirming" }; - case "error": - return { kind: "failed" }; - default: - return { kind: "ready", title }; - } -} diff --git a/app/src/lib/identity/mutations.ts b/app/src/lib/identity/mutations.ts index daa689999..51e4a8a4c 100644 --- a/app/src/lib/identity/mutations.ts +++ b/app/src/lib/identity/mutations.ts @@ -14,18 +14,3 @@ export function unlinkMutationOptions(queryClient: QueryClient) { queryClient.invalidateQueries({ queryKey: identityKeys.links() }), }); } - -export function confirmChallengeMutationOptions() { - return mutationOptions({ - mutationFn: (token: string): Promise<{ hint: string | null }> => - client<{ hint: string | null }>( - "/api/identity/challenges/confirm", - "confirmation", - { - method: "POST", - body: { token }, - fallback: "This link could not be confirmed", - }, - ), - }); -} diff --git a/app/src/lib/identity/queries.ts b/app/src/lib/identity/queries.ts index 29a12f42f..57e97fc93 100644 --- a/app/src/lib/identity/queries.ts +++ b/app/src/lib/identity/queries.ts @@ -10,18 +10,9 @@ export type LinkedAccount = { createdAt: string; }; -/** What the confirm page learns about a link challenge before the person agrees to it. */ -export type LinkChallenge = { - provider: LinkedAccount["provider"]; - title: string; - handle: string | null; -}; - export const identityKeys = { all: ["identity"] as const, links: () => [...identityKeys.all, "links"] as const, - challenge: (token: string) => - [...identityKeys.all, "challenge", token] as const, }; export function linkedAccountsQueryOptions() { @@ -33,18 +24,3 @@ export function linkedAccountsQueryOptions() { }), }); } - -/** A POST because the token is a secret and does not belong in a URL the server logs. */ -export function challengeQueryOptions(token: string) { - return queryOptions({ - queryKey: identityKeys.challenge(token), - queryFn: async (): Promise => - client("/api/identity/challenges/peek", "challenge", { - method: "POST", - body: { token }, - fallback: "This link expired or was already used", - }), - retry: false, - staleTime: Infinity, - }); -} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 3490f6ffc..6b5eb9d39 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -36,7 +36,6 @@ import { Route as AuthedAdminPeopleRouteImport } from './routes/_authed/admin/pe import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground' import { Route as AuthedAdminSkillsRouteImport } from './routes/_authed/admin/skills' import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index' -import { Route as AuthedSettingsLinkRouteImport } from './routes/_authed/settings/link' import { Route as AuthedSettingsPasswordsRouteImport } from './routes/_authed/settings/passwords' import { Route as AuthedSignInRequestIdRouteImport } from './routes/_authed/sign-in/$requestId' import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index' @@ -193,11 +192,6 @@ const AuthedSettingsIndexRoute = AuthedSettingsIndexRouteImport.update({ path: '/', getParentRoute: () => AuthedSettingsRouteRoute, } as any) -const AuthedSettingsLinkRoute = AuthedSettingsLinkRouteImport.update({ - id: '/link', - path: '/link', - getParentRoute: () => AuthedSettingsRouteRoute, -} as any) const AuthedSettingsPasswordsRoute = AuthedSettingsPasswordsRouteImport.update({ id: '/passwords', path: '/passwords', @@ -333,7 +327,6 @@ export interface FileRoutesByFullPath { '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute - '/settings/link': typeof AuthedSettingsLinkRoute '/settings/passwords': typeof AuthedSettingsPasswordsRoute '/sign-in/$requestId': typeof AuthedSignInRequestIdRoute '/admin/': typeof AuthedAdminIndexRoute @@ -379,7 +372,6 @@ export interface FileRoutesByTo { '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute - '/settings/link': typeof AuthedSettingsLinkRoute '/settings/passwords': typeof AuthedSettingsPasswordsRoute '/sign-in/$requestId': typeof AuthedSignInRequestIdRoute '/admin': typeof AuthedAdminIndexRoute @@ -429,7 +421,6 @@ export interface FileRoutesById { '/_authed/admin/people': typeof AuthedAdminPeopleRoute '/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute '/_authed/admin/skills': typeof AuthedAdminSkillsRoute - '/_authed/settings/link': typeof AuthedSettingsLinkRoute '/_authed/settings/passwords': typeof AuthedSettingsPasswordsRoute '/_authed/sign-in/$requestId': typeof AuthedSignInRequestIdRoute '/_authed/_app/': typeof AuthedAppIndexRoute @@ -480,7 +471,6 @@ export interface FileRouteTypes { | '/admin/people' | '/admin/playground' | '/admin/skills' - | '/settings/link' | '/settings/passwords' | '/sign-in/$requestId' | '/admin/' @@ -526,7 +516,6 @@ export interface FileRouteTypes { | '/admin/people' | '/admin/playground' | '/admin/skills' - | '/settings/link' | '/settings/passwords' | '/sign-in/$requestId' | '/admin' @@ -575,7 +564,6 @@ export interface FileRouteTypes { | '/_authed/admin/people' | '/_authed/admin/playground' | '/_authed/admin/skills' - | '/_authed/settings/link' | '/_authed/settings/passwords' | '/_authed/sign-in/$requestId' | '/_authed/_app/' @@ -797,13 +785,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsIndexRouteImport parentRoute: typeof AuthedSettingsRouteRoute } - '/_authed/settings/link': { - id: '/_authed/settings/link' - path: '/link' - fullPath: '/settings/link' - preLoaderRoute: typeof AuthedSettingsLinkRouteImport - parentRoute: typeof AuthedSettingsRouteRoute - } '/_authed/settings/passwords': { id: '/_authed/settings/passwords' path: '/passwords' @@ -993,7 +974,6 @@ const AuthedAdminRouteRouteWithChildren = AuthedAdminRouteRoute._addFileChildren(AuthedAdminRouteRouteChildren) interface AuthedSettingsRouteRouteChildren { - AuthedSettingsLinkRoute: typeof AuthedSettingsLinkRoute AuthedSettingsPasswordsRoute: typeof AuthedSettingsPasswordsRoute AuthedSettingsIndexRoute: typeof AuthedSettingsIndexRoute AuthedSettingsComponentsGalleryNameRoute: typeof AuthedSettingsComponentsGalleryNameRoute @@ -1003,7 +983,6 @@ interface AuthedSettingsRouteRouteChildren { } const AuthedSettingsRouteRouteChildren: AuthedSettingsRouteRouteChildren = { - AuthedSettingsLinkRoute: AuthedSettingsLinkRoute, AuthedSettingsPasswordsRoute: AuthedSettingsPasswordsRoute, AuthedSettingsIndexRoute: AuthedSettingsIndexRoute, AuthedSettingsComponentsGalleryNameRoute: diff --git a/app/src/routes/_authed/settings/link.tsx b/app/src/routes/_authed/settings/link.tsx deleted file mode 100644 index b9310e35b..000000000 --- a/app/src/routes/_authed/settings/link.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; -import { PageSection, PageShell } from "@/components/layout/page-shell"; -import { Button } from "@/components/ui/button"; -import { confirmLinkState } from "@/lib/identity/link-state"; -import { confirmChallengeMutationOptions } from "@/lib/identity/mutations"; -import { challengeQueryOptions } from "@/lib/identity/queries"; - -/** - * The browser half of a link a person started from a chat app. - * - * Confirming here proves which OpenBot user is asking; the link exists only once they finish from - * the outside account too. Nothing happens on load: opening the page is not consent. - */ -export const Route = createFileRoute("/_authed/settings/link")({ - component: RouteComponent, - validateSearch: (search: Record): { token?: string } => - typeof search.token === "string" ? { token: search.token } : {}, -}); - -function RouteComponent() { - const { token } = Route.useSearch(); - const valid = !!token && /^[A-Za-z0-9_-]{43}$/.test(token); - const peek = useQuery({ - ...challengeQueryOptions(token ?? ""), - enabled: valid, - }); - const confirm = useMutation(confirmChallengeMutationOptions()); - const state = confirmLinkState({ - token, - peek: { status: peek.status, data: peek.data }, - confirm: { status: confirm.status, hint: confirm.data?.hint }, - }); - - return ( - - - {state.kind === "invalid" && ( -

- This link is not valid. -

- )} - {state.kind === "expired" && ( -

- This link expired or was already used. Ask for a new one. -

- )} - {(state.kind === "ready" || state.kind === "confirming") && ( - <> -

- Only confirm if you asked for this link from your own{" "} - {state.title} account. You will then finish from {state.title}. -

- - - )} - {state.kind === "failed" && ( -

- This link expired, was already used, or was confirmed by a different - account. Ask for a new one. -

- )} - {state.kind === "done" && ( - <> -

- Confirmed. The link is not active until you finish from{" "} - {state.title ?? "the app you started from"}. -

- {state.hint && ( -
-                {state.hint}
-              
- )} - - )} -
-
- ); -} From 588d63ff207ea16e34f1d68bd7f54e456abcf197 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:15:36 -0300 Subject: [PATCH 027/101] fix(server): ignore identity links for unknown providers --- server/src/identity/store.ts | 34 ++++++++++++++++--- .../tests/identity-store.integration.test.ts | 18 ++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index 3f62e8e86..48634711b 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -25,10 +25,14 @@ const identityMatches = (identity: Identity) => eq(identityLinks.subject, identity.subject), ); -function toLink(row: typeof identityLinks.$inferSelect): IdentityLink { +/** `provider` is the row's provider, already checked against the registry by the caller. */ +function toLink( + row: typeof identityLinks.$inferSelect, + provider: IdentityProvider, +): IdentityLink { return { id: row.id, - provider: row.provider as IdentityProvider, + provider, realm: row.realm, subject: row.subject, userId: row.userId, @@ -123,7 +127,10 @@ export async function writeLink( }) .where(eq(identityLinks.id, existing.id)) .returning(); - return toLink(updated as typeof identityLinks.$inferSelect); + return toLink( + updated as typeof identityLinks.$inferSelect, + identity.provider, + ); } const [previous] = await transaction .select() @@ -159,7 +166,10 @@ export async function writeLink( updatedAt: now, }) .returning(); - return toLink(inserted as typeof identityLinks.$inferSelect); + return toLink( + inserted as typeof identityLinks.$inferSelect, + identity.provider, + ); } catch (error) { if (uniqueViolationConstraint(error) === "identity_links_identity_idx") throw new IdentityConflictError(); @@ -205,7 +215,21 @@ export function createIdentityStore(database: Database, credentials: Revoke) { : eq(identityLinks.userId, userId), ) .orderBy(identityLinks.provider, identityLinks.createdAt); - return rows.map(toLink); + // `provider` is free text, so a row written by a removed or future integration can carry one + // this build does not know. Skip it rather than hand callers a provider they cannot render. + const links: IdentityLink[] = []; + for (const row of rows) { + if (isIdentityProvider(row.provider)) + links.push(toLink(row, row.provider)); + else + console.warn( + JSON.stringify({ + type: "identity-link-unknown-provider", + linkId: row.id, + }), + ); + } + return links; }, async linkVerified( diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index 4a16d9fe9..559014786 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -192,3 +192,21 @@ test("a link outlives its user: nothing cascades from users", async () => { "user-that-never-existed", ); }); + +test("identitiesFor skips a link whose provider the registry does not know", async () => { + const person = user(); + const valid = await store.linkVerified(github(), person, { + method: "oauth", + }); + await database.insert(identityLinks).values({ + id: randomUUID(), + provider: "myspace", + realm, + subject: randomUUID(), + userId: person, + verifiedBy: "oauth", + status: "active", + }); + const links = await store.identitiesFor(person); + expect(links.map((link) => link.id)).toEqual([valid.id]); +}); From a38be44a4287ab38439fdb1922b90155d164eb0a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:16:01 -0300 Subject: [PATCH 028/101] fix(server): revoke linked tokens when a person is removed Removing a person retired their plugin connections but left the tokens on their identity links live. retireIdentityLinks revokes each linked credential and marks the link needs_reconnect with a null credential_id, keeping the rows so the outside account is still refused, not treated as a guest. Call sites of retireIdentityLinks: one, the person-removal retirer passed to createPeopleStore in server/src/index.ts, after retireConnectionsFor. --- server/src/identity/retire.ts | 51 ++++++++++++++++ server/src/index.ts | 8 ++- .../tests/identity-retire.integration.test.ts | 59 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 server/src/identity/retire.ts create mode 100644 server/tests/identity-retire.integration.test.ts diff --git a/server/src/identity/retire.ts b/server/src/identity/retire.ts new file mode 100644 index 000000000..fed55d974 --- /dev/null +++ b/server/src/identity/retire.ts @@ -0,0 +1,51 @@ +import { eq } from "drizzle-orm"; +import { CredentialRefusedError, type CredentialStore } from "../credentials"; +import type { Database } from "../db/client"; +import { identityLinks } from "../db/schema"; + +/** + * Retires the credentials behind a removed person's identity links. + * + * The link rows stay: `user_id` deliberately has no foreign key, so the outside account keeps + * resolving to the removed person and is refused, rather than turning into an unlinked guest. What + * must not stay live is the token each link holds, so every one is revoked, detached from its link, + * and the link marked `needs_reconnect`. One transaction, so a failure leaves nothing half-retired. + * + * Returns how many links had a credential retired; a second call returns 0. + */ +export async function retireIdentityLinks( + database: Database, + credentials: Pick, + userId: string, +): Promise { + return database.transaction(async (transaction) => { + const links = await transaction + .select({ + id: identityLinks.id, + credentialId: identityLinks.credentialId, + }) + .from(identityLinks) + .where(eq(identityLinks.userId, userId)) + .for("update"); + let retired = 0; + for (const link of links) { + if (!link.credentialId) continue; + try { + await credentials.revoke(link.credentialId, transaction); + } catch (error) { + // Already revoked is the outcome we wanted. + if (!(error instanceof CredentialRefusedError)) throw error; + } + await transaction + .update(identityLinks) + .set({ + credentialId: null, + status: "needs_reconnect", + updatedAt: new Date(), + }) + .where(eq(identityLinks.id, link.id)); + retired += 1; + } + return retired; + }); +} diff --git a/server/src/index.ts b/server/src/index.ts index a0695c9b6..51516f4c6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -176,6 +176,7 @@ import { HostAccessRefusedError, } from "./host-access/broker"; import { hostAccessTools } from "./host-access/tools"; +import { retireIdentityLinks } from "./identity/retire"; import { createIdentityStore } from "./identity/store"; import { createIntelligenceClient, @@ -400,7 +401,12 @@ const peopleStore = createPeopleStore( * during module initialisation — it runs when an administrator removes somebody, over HTTP — so by * then the binding is there. */ - (userId, by) => pluginStore.retireConnectionsFor(userId, by), + async (userId, by) => { + const plugins = await pluginStore.retireConnectionsFor(userId, by); + // Their linked outside accounts keep resolving to them, now refused; only the tokens go. + await retireIdentityLinks(database, credentialStore, userId); + return plugins; + }, ); const identityProviderStore = createIdentityProviderStore(database); /* diff --git a/server/tests/identity-retire.integration.test.ts b/server/tests/identity-retire.integration.test.ts new file mode 100644 index 000000000..d1d802139 --- /dev/null +++ b/server/tests/identity-retire.integration.test.ts @@ -0,0 +1,59 @@ +import { afterAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { credentials, identityLinks } from "../src/db/schema"; +import { retireIdentityLinks } from "../src/identity/retire"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const vault = createCredentialStore(database); +const realm = `test-${randomUUID()}`; +const createdCredentials: string[] = []; + +afterAll(async () => { + await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); + if (createdCredentials.length) + await database + .delete(credentials) + .where(inArray(credentials.id, createdCredentials)); + await database.$client.close(); +}); + +test("retiring a removed person's links revokes their tokens and keeps the rows", async () => { + const userId = randomUUID(); + const credential = await vault.create({ + kind: "connector", + provider: "github-user-token", + keyId: randomUUID(), + metadata: {}, + encryptedValue: "x", + }); + createdCredentials.push(credential.id); + const linkId = randomUUID(); + await database.insert(identityLinks).values({ + id: linkId, + provider: "github", + realm, + subject: randomUUID(), + userId, + verifiedBy: "oauth", + credentialId: credential.id, + status: "active", + }); + expect(await vault.isLive(credential.id)).toBe(true); + + expect(await retireIdentityLinks(database, vault, userId)).toBe(1); + + expect(await vault.isLive(credential.id)).toBe(false); + const [row] = await database + .select() + .from(identityLinks) + .where(eq(identityLinks.id, linkId)); + expect(row?.userId).toBe(userId); + expect(row?.credentialId).toBeNull(); + expect(row?.status).toBe("needs_reconnect"); + + expect(await retireIdentityLinks(database, vault, userId)).toBe(0); +}); From b64b158a0ee818250f5eba499a2059c16354834b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:16:12 -0300 Subject: [PATCH 029/101] fix(app): quieter, per-account linked account errors --- .../settings/linked-accounts.test.tsx | 38 +++++++- .../components/settings/linked-accounts.tsx | 94 +++++++++++-------- app/src/lib/identity/queries.ts | 30 +++++- 3 files changed, 120 insertions(+), 42 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index 96f42f6e1..29bc89ade 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -1,5 +1,7 @@ import { expect, test } from "bun:test"; -import { linkedAccountDescription } from "./linked-accounts"; +import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; +import { linksFromResponse } from "@/lib/identity/queries"; +import { linkedAccountDescription, providerIcon } from "./linked-accounts"; const base = { id: "1", @@ -29,3 +31,37 @@ test("a link needing reconnection says so first", () => { }), ).toBe("Needs reconnecting · @dana"); }); + +test("a reconnect with no handle does not also say linked", () => { + expect( + linkedAccountDescription({ + ...base, + handle: null, + status: "needs_reconnect", + }), + ).toBe("Needs reconnecting"); +}); + +test("providers map to their own icon with a neutral fallback", () => { + expect(providerIcon("slack")).toBe(IconBrandSlack); + expect(providerIcon("github")).toBe(IconBrandGithub); + expect(providerIcon("gitlab")).toBe(IconLink); +}); + +test("a 503 means the feature is absent, so no links", async () => { + const response = Response.json( + { error: "Linked accounts are not available." }, + { status: 503 }, + ); + expect(await linksFromResponse(response)).toEqual([]); +}); + +test("other failures still throw the server message", async () => { + const response = Response.json({ error: "boom" }, { status: 500 }); + await expect(linksFromResponse(response)).rejects.toThrow("boom"); +}); + +test("a success unwraps the links", async () => { + const links = [{ ...base, handle: "dana", status: "active" as const }]; + expect(await linksFromResponse(Response.json({ links }))).toEqual(links); +}); diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index b001d6d7c..0ddaf63c2 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -1,4 +1,4 @@ -import { IconBrandGithub, IconBrandSlack } from "@tabler/icons-react"; +import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { PageRows, PageSection } from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; @@ -19,10 +19,25 @@ import { import { queryClient } from "@/query-client"; export function linkedAccountDescription(account: LinkedAccount): string { - const who = account.handle ? `@${account.handle}` : "Linked"; - return account.status === "needs_reconnect" - ? `Needs reconnecting · ${who}` - : who; + if (account.status === "needs_reconnect") { + return account.handle + ? `Needs reconnecting · @${account.handle}` + : "Needs reconnecting"; + } + return account.handle ? `@${account.handle}` : "Linked"; +} + +const providerIcons = { + slack: IconBrandSlack, + github: IconBrandGithub, +} as const; + +/** A provider this build has no mark for gets a neutral link icon rather than someone else's logo. */ +export function providerIcon(provider: string) { + return ( + (providerIcons as Record)[provider] ?? + IconLink + ); } /** @@ -44,45 +59,50 @@ export function LinkedAccountsSection() {
); } - // Deliberately hidden when nothing is linked. + const failedTitle = links.data.find( + (account) => account.id === unlink.variables, + )?.title; + // Deliberately hidden when nothing is linked, or when this deployment has no identity store. if (links.data.length === 0) return null; return ( - {links.data.map((account, index) => ( -
- {index > 0 ? : null} - - - {account.provider === "slack" ? ( - - ) : ( - - )} - - - {account.title} - - {linkedAccountDescription(account)} - - - - - - -
- ))} + {links.data.map((account, index) => { + const Icon = providerIcon(account.provider); + return ( +
+ {index > 0 ? : null} + + + + + + {account.title} + + {linkedAccountDescription(account)} + + + + + + +
+ ); + })}
{unlink.error ? (

+ Could not disconnect {failedTitle ?? "that account"}:{" "} {unlink.error.message}

) : null} diff --git a/app/src/lib/identity/queries.ts b/app/src/lib/identity/queries.ts index 57e97fc93..95b8449cf 100644 --- a/app/src/lib/identity/queries.ts +++ b/app/src/lib/identity/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; -import { client } from "@/lib/client"; +import { tryClient } from "@/lib/client"; export type LinkedAccount = { id: string; @@ -15,12 +15,34 @@ export const identityKeys = { links: () => [...identityKeys.all, "links"] as const, }; +const LOAD_FALLBACK = "Could not load your linked accounts"; + +/** + * A deployment without an identity store answers 503 "not available". That is the feature being + * absent, not the server breaking, so it reads as no links and the section hides itself. + */ +export async function linksFromResponse( + response: Response, +): Promise { + if (response.status === 503) return []; + if (!response.ok) { + const message = await response + .json() + .then((body: { error?: string }) => body.error) + .catch(() => undefined); + throw new Error(message ?? LOAD_FALLBACK); + } + const body = (await response.json().catch(() => null)) as { + links?: LinkedAccount[]; + } | null; + if (!body || !Array.isArray(body.links)) throw new Error(LOAD_FALLBACK); + return body.links; +} + export function linkedAccountsQueryOptions() { return queryOptions({ queryKey: identityKeys.links(), queryFn: async (): Promise => - client("/api/identity/links", "links", { - fallback: "Could not load your linked accounts", - }), + linksFromResponse(await tryClient("/api/identity/links")), }); } From 93a2a621887a55bf61e8ddfeff5a69e6b54a04c1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:16:25 -0300 Subject: [PATCH 030/101] fix(server): audit disconnecting a linked account --- server/src/app.ts | 2 +- server/src/audit.ts | 2 ++ server/src/identity/routes.ts | 27 ++++++++++++++++-- server/tests/identity-routes.test.ts | 41 +++++++++++++++++++++++----- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 4dbf8be53..77941f983 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -685,7 +685,7 @@ export function createApp( * A person's own linked accounts at outside providers (Slack, GitHub). Theirs alone: there is no * administrator view and no linking on somebody else's behalf. */ - app.route("/api/identity", identityRoutes(requireUser, identity)); + app.route("/api/identity", identityRoutes(requireUser, identity, auditStore)); app.get("/api/settings/instructions", requireUser, async (context) => { if (!userInstructions) { diff --git a/server/src/audit.ts b/server/src/audit.ts index 990453831..50c454c9e 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -511,6 +511,8 @@ export const auditEventTypes = [ /* A Bot connected to, or disconnected from, a Slack, Teams or SMS conversation. */ "delivery.linked", "delivery.unlinked", + /* A person removing their own linked account at an outside provider (Slack, GitHub). */ + "identity.unlinked", /* Routines, however they were made: by a person on the screen or by a Bot through its tools. */ "routine.created", "routine.updated", diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index a11ec7a15..a0066c86c 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -1,4 +1,5 @@ import { Hono, type MiddlewareHandler } from "hono"; +import { type AuditStore, recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; import { PROVIDERS } from "./providers"; import type { IdentityStore } from "./store"; @@ -13,6 +14,7 @@ import type { IdentityStore } from "./store"; export function identityRoutes( requireUser: MiddlewareHandler<{ Variables: AppVariables }>, store?: Pick, + auditStore?: AuditStore, ) { const app = new Hono<{ Variables: AppVariables }>(); app.use("*", async (context, next) => { @@ -43,12 +45,31 @@ export function identityRoutes( }); app.delete("/links/:id", async (context) => { - const removed = await live.unlink( - context.var.actor.id, - context.req.param("id"), + const actorId = context.var.actor.id; + const id = context.req.param("id"); + const link = (await live.identitiesFor(actorId)).find( + (candidate) => candidate.id === id, ); + const removed = await live.unlink(actorId, id); if (!removed) return context.json({ error: "Linked account not found." }, 404); + /* + * After the removal, and not caught: the same order and the same failure as the connected-account + * disconnect (`mcp.account_disconnected`), where an audit write that throws fails the request. + * Repeating it is safe because the link is already gone and answers 404. + * + * Names the link and its provider only. The subject, realm and credential id stay out of the + * trail, as they stay out of every response here. + */ + if (auditStore) { + await recordAuditEvent(auditStore, { + eventType: "identity.unlinked", + targetType: "identity_link", + targetId: id, + actorUserId: actorId, + payload: { actor: actorId, provider: link?.provider }, + }); + } return context.body(null, 204); }); diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index d10f0e1e9..07c3c8d14 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; +import type { AuditEventInput } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; import { identityRoutes } from "../src/identity/routes"; import type { IdentityLink } from "../src/identity/types"; @@ -31,14 +32,19 @@ const link: IdentityLink = { function fixture() { const calls: string[] = []; - const app = identityRoutes(signedIn, { - identitiesFor: async (userId) => (userId === "person" ? [link] : []), - unlink: async (userId, id) => { - calls.push(`unlink:${userId}:${id}`); - return userId === "person" && id === "link-1"; + const events: AuditEventInput[] = []; + const app = identityRoutes( + signedIn, + { + identitiesFor: async (userId) => (userId === "person" ? [link] : []), + unlink: async (userId, id) => { + calls.push(`unlink:${userId}:${id}`); + return userId === "person" && id === "link-1"; + }, }, - }); - return { app, calls }; + { insert: async (event) => void events.push(event) }, + ); + return { app, calls, events }; } test("links lists the asker's links without subject, realm or credential", async () => { @@ -103,3 +109,24 @@ test("auth runs before the missing-store 503 and responses are never cached", as expect(response.status).toBe(401); expect(response.headers.get("cache-control")).toBe("no-store"); }); + +test("a successful DELETE records one event naming actor, link and provider only", async () => { + const f = fixture(); + await f.app.request("/links/link-1", { method: "DELETE" }); + expect(f.events).toHaveLength(1); + expect(f.events[0]).toMatchObject({ + eventType: "identity.unlinked", + targetId: "link-1", + actorUserId: "person", + payload: { actor: "person", provider: "github" }, + }); + const text = JSON.stringify(f.events[0]); + for (const secret of ["secret-credential-id", "github.com", "42"]) + expect(text).not.toContain(`"${secret}"`); +}); + +test("a 404 DELETE records nothing", async () => { + const f = fixture(); + await f.app.request("/links/someone-elses", { method: "DELETE" }); + expect(f.events).toEqual([]); +}); From 25e6f6f5ec86ffd74f0a866cbb8e1ca6d2fd9afa Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:16:25 -0300 Subject: [PATCH 031/101] fix(server): refuse identity links to unusable credentials --- server/src/identity/store.ts | 29 ++++++++++++-- .../tests/identity-store.integration.test.ts | 39 +++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index 48634711b..df83ff55d 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -17,6 +17,7 @@ import { type Transaction = Parameters[0]>[0]; type Revoke = Pick; +type Vault = Pick; const identityMatches = (identity: Identity) => and( @@ -186,7 +187,7 @@ function codeHash(code: string) { return createHash("sha256").update(code.toLowerCase()).digest("hex"); } -export function createIdentityStore(database: Database, credentials: Revoke) { +export function createIdentityStore(database: Database, credentials: Vault) { return { async linkedUser( value: Identity, @@ -232,6 +233,15 @@ export function createIdentityStore(database: Database, credentials: Revoke) { return links; }, + /** + * Link `value` to `userId` on proof the caller has already verified. + * + * A `credentialId`, when given, must name a live credential: it is checked in the link's own + * transaction, so a missing or revoked id is refused rather than stored as a token the link + * does not have. A "challenge" link proves control by code and carries no credential, so it + * refuses one. An "oauth" link is not required to carry one: GitHub sign-in may link before + * a token is stored. + */ async linkVerified( value: Identity, userId: string, @@ -247,9 +257,20 @@ export function createIdentityStore(database: Database, credentials: Revoke) { throw new IdentityInputError( "This provider does not accept that kind of proof.", ); - return database.transaction((transaction) => - writeLink(transaction, credentials, identity, userId, proof), - ); + if (proof.credentialId && proof.method === "challenge") + throw new IdentityInputError( + "A challenge link does not carry a credential.", + ); + return database.transaction(async (transaction) => { + if ( + proof.credentialId && + !(await credentials.isLive(proof.credentialId, transaction)) + ) + throw new IdentityInputError( + "The credential for this link is not available.", + ); + return writeLink(transaction, credentials, identity, userId, proof); + }); }, async markNeedsReconnect(linkId: string): Promise { diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index 559014786..ddf94cd51 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -210,3 +210,42 @@ test("identitiesFor skips a link whose provider the registry does not know", asy const links = await store.identitiesFor(person); expect(links.map((link) => link.id)).toEqual([valid.id]); }); + +test("a credential that does not exist is refused and no link is written", async () => { + const identity = github(); + await expect( + store.linkVerified(identity, user(), { + method: "oauth", + credentialId: randomUUID(), + }), + ).rejects.toThrow(IdentityInputError); + expect(await store.linkedUser(identity)).toBeNull(); +}); + +test("a revoked credential is refused and no link is written", async () => { + const identity = github(); + const id = await credential(); + await vault.revoke(id); + await expect( + store.linkVerified(identity, user(), { + method: "oauth", + credentialId: id, + }), + ).rejects.toThrow(IdentityInputError); + expect(await store.linkedUser(identity)).toBeNull(); +}); + +test("a challenge link refuses a credential", async () => { + const identity: Identity = { + provider: "slack", + realm, + subject: randomUUID(), + }; + await expect( + store.linkVerified(identity, user(), { + method: "challenge", + credentialId: await credential(), + }), + ).rejects.toThrow(IdentityInputError); + expect(await store.linkedUser(identity)).toBeNull(); +}); From 3da32231592b1ccfb4ed695b49780ef2ee254e9e Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:23:41 -0300 Subject: [PATCH 032/101] docs: describe linked accounts as they stand --- docs/configuration.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 98a64aeac..588b13023 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -485,20 +485,23 @@ SMS through Twilio honours Advanced Opt-Out: `STOP` (and Twilio's other opt-out number opted out, later messages show `opted_out` in the delivery history instead of being sent, and `START` resumes. Twilio sends the confirmation reply itself, so OpenBot does not. -### Linked accounts - -A person's accounts at outside providers — their Slack user, their GitHub account — are linked to -their OpenBot user in one place, `identity_links`, and listed under **Linked accounts** on -`/settings/connected-accounts`, where they can disconnect any of them. A link is proven one of two -ways: by the provider's own sign-in (GitHub, when that integration is configured), or by a one-time -code OpenBot issues to the signed-in person, which they send from their chat account (Slack). A -code always links to the person it was issued to, so nobody can link an account on somebody else's -behalf, and an outside account links to one OpenBot user at most. Linking providers are added by -their integrations; until one is configured, the list is empty. - -This complements, rather than replaces, the OpenTag **Reachability** pairing above: that pairing -binds a conversation and Bot for delivery, while a linked account records who a person is at the -provider. +## Linked accounts + +OpenBot keeps a record of a person's accounts at outside providers, such as their GitHub account +or their Slack user. When a person has any, they appear under **Linked accounts** on +`/settings/connected-accounts`, and each can be disconnected there. Disconnecting revokes any token +stored with the link, and is audited. A person with none sees no such section, and a deployment +without an identity store does not show it at all. + +Links are created by the integrations that support them, and none ship enabled yet, so the list is +empty until one is. An integration proves an account in one of two ways: by the provider's own +sign-in, or by a one-time code that OpenBot issues to the signed-in person and that they send from +their chat account. A code links only to the person it was issued to, and an outside account links to +one OpenBot user at most. + +This is separate from the OpenTag **Reachability** pairing above. That pairing binds a conversation +for delivery and uses its own `link ` messages, shown on the **Reachability** screen; it is not +recorded as a linked account. ## Text messages and push notifications From 116d79d3f30dc1cfe573ce305c1cc492a96bb8df Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:23:46 -0300 Subject: [PATCH 033/101] fix(server): refuse blank identity parts --- server/src/identity/providers.ts | 16 ++++++++--- server/tests/identity-providers.test.ts | 36 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/server/src/identity/providers.ts b/server/src/identity/providers.ts index 761d96c25..ab2025837 100644 --- a/server/src/identity/providers.ts +++ b/server/src/identity/providers.ts @@ -25,6 +25,8 @@ export const PROVIDERS: Readonly> = github: { title: "GitHub", methods: ["oauth"] }, }); +const REALM_MAX = 512; + export const GITHUB_DOTCOM_REALM = "github.com"; export function isIdentityProvider(value: unknown): value is IdentityProvider { @@ -46,15 +48,21 @@ export function slackRealm(parts: { workspaceId: string; }): string { const values = [parts.connectionId, parts.installationId, parts.workspaceId]; - if (values.some((value) => !value.trim())) + if (values.some((value) => !value.trim() || value !== value.trim())) throw new IdentityInputError("Slack identity is incomplete."); - return values.map(encodeURIComponent).join(":"); + const realm = values.map(encodeURIComponent).join(":"); + if (realm.length > REALM_MAX) + throw new IdentityInputError("Slack identity is too long."); + return realm; } +// Provider ids never contain whitespace: reject blank values but keep others exactly as given. +const nonBlank = (value: string) => value.trim().length > 0; + const identitySchema = z.object({ provider: z.string().refine(isIdentityProvider), - realm: z.string().min(1).max(512), - subject: z.string().min(1).max(256), + realm: z.string().max(REALM_MAX).refine(nonBlank), + subject: z.string().max(256).refine(nonBlank), }); /** Explicit fields only, so extra runtime properties never reach the persistence contract. */ diff --git a/server/tests/identity-providers.test.ts b/server/tests/identity-providers.test.ts index 129297415..91cb9ef7a 100644 --- a/server/tests/identity-providers.test.ts +++ b/server/tests/identity-providers.test.ts @@ -83,3 +83,39 @@ test("parseIdentity strips extra properties and bounds sizes", () => { parseIdentity({ provider: "slack", realm: "", subject: "s" }), ).toThrow(IdentityInputError); }); + +test("parseIdentity refuses a whitespace-only realm or subject", () => { + expect(() => + parseIdentity({ provider: "slack", realm: " ", subject: "U1" }), + ).toThrow(IdentityInputError); + expect(() => + parseIdentity({ provider: "slack", realm: "T1", subject: " \t" }), + ).toThrow(IdentityInputError); +}); + +test("slackRealm refuses parts with surrounding whitespace", () => { + expect(() => + slackRealm({ + connectionId: "C1", + installationId: "I1", + workspaceId: " T1", + }), + ).toThrow(IdentityInputError); + expect(() => + slackRealm({ + connectionId: "C1 ", + installationId: "I1", + workspaceId: "T1", + }), + ).toThrow(IdentityInputError); +}); + +test("slackRealm refuses a realm longer than parseIdentity accepts", () => { + expect(() => + slackRealm({ + connectionId: "C".repeat(200), + installationId: "I".repeat(200), + workspaceId: "T".repeat(200), + }), + ).toThrow(IdentityInputError); +}); From 01d825d9e2c0dc4c4783718e5adf27488b63bc8c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:23:59 -0300 Subject: [PATCH 034/101] docs: warn that a linked-account code must not be shared --- docs/configuration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 588b13023..6918a4a8b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -496,7 +496,9 @@ without an identity store does not show it at all. Links are created by the integrations that support them, and none ship enabled yet, so the list is empty until one is. An integration proves an account in one of two ways: by the provider's own sign-in, or by a one-time code that OpenBot issues to the signed-in person and that they send from -their chat account. A code links only to the person it was issued to, and an outside account links to +their chat account. Whoever sends the code gets that chat account linked to the person it was issued +to, so treat it like a password and do not share it. It is valid for ten minutes and works once, and +issuing a new code for the same provider invalidates the earlier one. An outside account links to one OpenBot user at most. This is separate from the OpenTag **Reachability** pairing above. That pairing binds a conversation From 7ec2bc4d4920f7fe2f459b6437627c98fa40db01 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:25:04 -0300 Subject: [PATCH 035/101] fix(server): retire linked accounts completely when a person is removed Removing a person now retires every linked account, whichever half fails: - retireOwnedAccounts runs the plugin and identity retirements both, then rethrows the first failure (AggregateError if both fail); the count is the sum. A Composio refusal no longer leaves linked tokens live. - retireIdentityLinks deletes the person's pending link challenges in the same transaction, so a code issued before removal cannot be redeemed. - Every link is marked needs_reconnect, including credential-less (Slack) links; rows are kept so the outside account resolves to the removed person and is refused. - writeLink's per-(user, provider, realm) advisory key is taken for each of the person's realms before the row locks; the residual window (a new realm's OAuth link in flight) is documented. - One identity.link_retired audit row per changed link: actor `by`, target the link id, payload { actor, provider, reason: "person_removed", credentialRevoked }. No subject, realm or credential id. Call sites of changed exports: - retireIdentityLinks (signature now database, credentials, auditStore, userId, by): server/src/index.ts (people-store hook), server/tests/identity-retire.integration.test.ts. - retireOwnedAccounts (new): server/src/index.ts, the same test. - auditEventTypes (+ "identity.link_retired"): closed union in server/src/audit.ts; no app-side mirror references identity.* types. --- server/src/audit.ts | 5 + server/src/identity/retire.ts | 138 ++++++++++-- server/src/index.ts | 24 ++- .../tests/identity-retire.integration.test.ts | 198 +++++++++++++++++- 4 files changed, 331 insertions(+), 34 deletions(-) diff --git a/server/src/audit.ts b/server/src/audit.ts index 50c454c9e..5d487905f 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -513,6 +513,11 @@ export const auditEventTypes = [ "delivery.unlinked", /* A person removing their own linked account at an outside provider (Slack, GitHub). */ "identity.unlinked", + /* + * A removed person's linked account retired by the removal: marked `needs_reconnect`, its token (if + * any) revoked. Names the link, its provider and the remover only. + */ + "identity.link_retired", /* Routines, however they were made: by a person on the screen or by a Bot through its tools. */ "routine.created", "routine.updated", diff --git a/server/src/identity/retire.ts b/server/src/identity/retire.ts index fed55d974..7b8fc1588 100644 --- a/server/src/identity/retire.ts +++ b/server/src/identity/retire.ts @@ -1,40 +1,89 @@ -import { eq } from "drizzle-orm"; +import { asc, eq, sql } from "drizzle-orm"; +import { type AuditStore, recordAuditEvent } from "../audit"; import { CredentialRefusedError, type CredentialStore } from "../credentials"; import type { Database } from "../db/client"; -import { identityLinks } from "../db/schema"; +import { identityLinkChallenges, identityLinks } from "../db/schema"; /** - * Retires the credentials behind a removed person's identity links. + * Retires a removed person's identity links. * - * The link rows stay: `user_id` deliberately has no foreign key, so the outside account keeps - * resolving to the removed person and is refused, rather than turning into an unlinked guest. What - * must not stay live is the token each link holds, so every one is revoked, detached from its link, - * and the link marked `needs_reconnect`. One transaction, so a failure leaves nothing half-retired. + * Every one of the person's links is marked `needs_reconnect`, whatever its provider: a link with a + * credential has that credential revoked and detached (`credential_id` null), and a link without one + * (Slack) changes status only. The rows are NOT deleted — `user_id` deliberately has no foreign key, + * so the outside account keeps resolving to the removed person and is refused, rather than turning + * into an unlinked guest. The person's pending link challenges are deleted, so a code issued before + * the removal cannot be redeemed into a fresh active link afterwards. * - * Returns how many links had a credential retired; a second call returns 0. + * One transaction, so a failure leaves nothing half-retired. One `identity.link_retired` audit row + * per link that changed, written after the commit (the audit store has its own handle — the same + * order `retireConnectionsFor` uses): actor `by`, target the link id, and the provider, the reason + * and whether a credential was revoked. Never the subject, realm or credential id. + * + * Concurrency. The challenges are deleted first: a `redeemChallenge` in flight holds its challenge + * row, so the delete waits for it to commit and the link it wrote is then seen below. Then, for + * every realm the person has a link in, the exact advisory key `writeLink` takes for + * `(userId, provider, realm)` is taken — in sorted order, before any row lock, the order `writeLink` + * uses — so a concurrent re-link in a known realm finishes first or waits for this. The residual + * window: a `writeLink` that is not a challenge redemption (an OAuth link) into a realm this person + * had no link in, still uncommitted when the realms are read, commits an active link after this + * returns. The person's sessions are already gone by then, so that needs a callback already past + * its session check. + * + * Returns how many links changed; a second call returns 0 and writes no audit rows. */ export async function retireIdentityLinks( database: Database, credentials: Pick, + auditStore: AuditStore, userId: string, + by: string, ): Promise { - return database.transaction(async (transaction) => { + const retired = await database.transaction(async (transaction) => { + await transaction + .delete(identityLinkChallenges) + .where(eq(identityLinkChallenges.userId, userId)); + + const realms = await transaction + .selectDistinct({ + provider: identityLinks.provider, + realm: identityLinks.realm, + }) + .from(identityLinks) + .where(eq(identityLinks.userId, userId)) + .orderBy(asc(identityLinks.provider), asc(identityLinks.realm)); + for (const { provider, realm } of realms) { + // writeLink's key, character for character (server/src/identity/store.ts). + await transaction.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`${userId}\u001f${provider}\u001f${realm}`}, 0))`, + ); + } + const links = await transaction .select({ id: identityLinks.id, + provider: identityLinks.provider, + status: identityLinks.status, credentialId: identityLinks.credentialId, }) .from(identityLinks) .where(eq(identityLinks.userId, userId)) + .orderBy(asc(identityLinks.id)) .for("update"); - let retired = 0; + + const changed: { + id: string; + provider: string; + credentialRevoked: boolean; + }[] = []; for (const link of links) { - if (!link.credentialId) continue; - try { - await credentials.revoke(link.credentialId, transaction); - } catch (error) { - // Already revoked is the outcome we wanted. - if (!(error instanceof CredentialRefusedError)) throw error; + if (!link.credentialId && link.status === "needs_reconnect") continue; + if (link.credentialId) { + try { + await credentials.revoke(link.credentialId, transaction); + } catch (error) { + // Already revoked is the outcome we wanted. + if (!(error instanceof CredentialRefusedError)) throw error; + } } await transaction .update(identityLinks) @@ -44,8 +93,61 @@ export async function retireIdentityLinks( updatedAt: new Date(), }) .where(eq(identityLinks.id, link.id)); - retired += 1; + changed.push({ + id: link.id, + provider: link.provider, + credentialRevoked: Boolean(link.credentialId), + }); } - return retired; + return changed; }); + + for (const link of retired) { + await recordAuditEvent(auditStore, { + eventType: "identity.link_retired", + targetType: "identity_link", + targetId: link.id, + actorUserId: by, + payload: { + actor: by, + provider: link.provider, + reason: "person_removed", + credentialRevoked: link.credentialRevoked, + }, + }); + } + return retired.length; +} + +/** + * Everything a removed person had connected, retired in full whichever half fails. + * + * Both halves always run: the plugin half rethrows on purpose (a broker refusal), and stopping there + * left every linked account's token live, with a retry hitting the same refusal first. The failure + * is still thrown afterwards — the first one alone, or an AggregateError of both — so the caller's + * error handling sees it. On success, the count is both halves together. + */ +export async function retireOwnedAccounts(retirers: { + plugins: () => Promise<{ retired: number }>; + identities: () => Promise; +}): Promise<{ retired: number }> { + const failures: unknown[] = []; + let retired = 0; + try { + retired += (await retirers.plugins()).retired; + } catch (error) { + failures.push(error); + } + try { + retired += await retirers.identities(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError( + failures, + "Retiring a removed person's accounts failed in more than one place.", + ); + return { retired }; } diff --git a/server/src/index.ts b/server/src/index.ts index 51516f4c6..1898acb67 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -176,7 +176,7 @@ import { HostAccessRefusedError, } from "./host-access/broker"; import { hostAccessTools } from "./host-access/tools"; -import { retireIdentityLinks } from "./identity/retire"; +import { retireIdentityLinks, retireOwnedAccounts } from "./identity/retire"; import { createIdentityStore } from "./identity/store"; import { createIntelligenceClient, @@ -401,12 +401,22 @@ const peopleStore = createPeopleStore( * during module initialisation — it runs when an administrator removes somebody, over HTTP — so by * then the binding is there. */ - async (userId, by) => { - const plugins = await pluginStore.retireConnectionsFor(userId, by); - // Their linked outside accounts keep resolving to them, now refused; only the tokens go. - await retireIdentityLinks(database, credentialStore, userId); - return plugins; - }, + /* + * Both halves run whichever fails, and the failure is rethrown after. Their linked outside + * accounts keep resolving to them, now refused; the tokens go and every link needs reconnecting. + */ + (userId, by) => + retireOwnedAccounts({ + plugins: () => pluginStore.retireConnectionsFor(userId, by), + identities: () => + retireIdentityLinks( + database, + credentialStore, + bootAuditStore, + userId, + by, + ), + }), ); const identityProviderStore = createIdentityProviderStore(database); /* diff --git a/server/tests/identity-retire.integration.test.ts b/server/tests/identity-retire.integration.test.ts index d1d802139..c7311196f 100644 --- a/server/tests/identity-retire.integration.test.ts +++ b/server/tests/identity-retire.integration.test.ts @@ -1,19 +1,35 @@ import { afterAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { eq, inArray } from "drizzle-orm"; +import type { AuditEventInput, AuditStore } from "../src/audit"; import { createCredentialStore } from "../src/credentials"; import { createDatabase } from "../src/db/client"; -import { credentials, identityLinks } from "../src/db/schema"; -import { retireIdentityLinks } from "../src/identity/retire"; +import { + credentials, + identityLinkChallenges, + identityLinks, +} from "../src/db/schema"; +import { + retireIdentityLinks, + retireOwnedAccounts, +} from "../src/identity/retire"; +import { createIdentityStore } from "../src/identity/store"; +import { IdentityLinkError } from "../src/identity/types"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const vault = createCredentialStore(database); const realm = `test-${randomUUID()}`; const createdCredentials: string[] = []; +const users: string[] = []; +const ADMIN = "admin-remover"; afterAll(async () => { await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); + if (users.length) + await database + .delete(identityLinkChallenges) + .where(inArray(identityLinkChallenges.userId, users)); if (createdCredentials.length) await database .delete(credentials) @@ -21,8 +37,32 @@ afterAll(async () => { await database.$client.close(); }); +const user = () => { + const id = randomUUID(); + users.push(id); + return id; +}; + +function recorder() { + const rows: AuditEventInput[] = []; + const store: AuditStore = { + insert: async (event) => { + rows.push(event); + }, + }; + return { rows, store }; +} + +const statusOf = async (linkId: string) => + ( + await database + .select() + .from(identityLinks) + .where(eq(identityLinks.id, linkId)) + )[0]; + test("retiring a removed person's links revokes their tokens and keeps the rows", async () => { - const userId = randomUUID(); + const userId = user(); const credential = await vault.create({ kind: "connector", provider: "github-user-token", @@ -43,17 +83,157 @@ test("retiring a removed person's links revokes their tokens and keeps the rows" status: "active", }); expect(await vault.isLive(credential.id)).toBe(true); + const audit = recorder(); - expect(await retireIdentityLinks(database, vault, userId)).toBe(1); + expect( + await retireIdentityLinks(database, vault, audit.store, userId, ADMIN), + ).toBe(1); expect(await vault.isLive(credential.id)).toBe(false); - const [row] = await database - .select() - .from(identityLinks) - .where(eq(identityLinks.id, linkId)); + const row = await statusOf(linkId); expect(row?.userId).toBe(userId); expect(row?.credentialId).toBeNull(); expect(row?.status).toBe("needs_reconnect"); - expect(await retireIdentityLinks(database, vault, userId)).toBe(0); + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0]).toMatchObject({ + eventType: "identity.link_retired", + targetType: "identity_link", + targetId: linkId, + actorUserId: ADMIN, + payload: { + actor: ADMIN, + provider: "github", + reason: "person_removed", + credentialRevoked: true, + }, + }); + const payload = JSON.stringify(audit.rows[0]?.payload); + expect(payload).not.toContain(realm); + expect(payload).not.toContain(credential.id); + + // A second pass changes nothing and records nothing. + expect( + await retireIdentityLinks(database, vault, audit.store, userId, ADMIN), + ).toBe(0); + expect(audit.rows).toHaveLength(1); +}); + +test("a link without a credential is also marked needs_reconnect, and audited without its subject", async () => { + const userId = user(); + const linkId = randomUUID(); + const subject = `U-${randomUUID()}`; + await database.insert(identityLinks).values({ + id: linkId, + provider: "slack", + realm, + subject, + userId, + verifiedBy: "challenge", + status: "active", + }); + const audit = recorder(); + + expect( + await retireIdentityLinks(database, vault, audit.store, userId, ADMIN), + ).toBe(1); + + const row = await statusOf(linkId); + expect(row?.userId).toBe(userId); + expect(row?.status).toBe("needs_reconnect"); + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0]).toMatchObject({ + eventType: "identity.link_retired", + targetId: linkId, + actorUserId: ADMIN, + payload: { + provider: "slack", + reason: "person_removed", + credentialRevoked: false, + }, + }); + const payload = JSON.stringify(audit.rows[0]?.payload); + expect(payload).not.toContain(subject); + expect(payload).not.toContain(realm); +}); + +test("a pending challenge cannot be redeemed after the person is removed", async () => { + const userId = user(); + const identities = createIdentityStore(database, vault); + const { code } = await identities.issueChallenge(userId, "slack"); + + await retireIdentityLinks(database, vault, recorder().store, userId, ADMIN); + + const pending = await database + .select() + .from(identityLinkChallenges) + .where(eq(identityLinkChallenges.userId, userId)); + expect(pending).toHaveLength(0); + await expect( + identities.redeemChallenge(code, { + provider: "slack", + realm, + subject: randomUUID(), + }), + ).rejects.toBeInstanceOf(IdentityLinkError); +}); + +test("a plugin refusal still retires identity links, and is rethrown", async () => { + const refusal = new Error("composio refused"); + const ran: string[] = []; + await expect( + retireOwnedAccounts({ + plugins: async () => { + ran.push("plugins"); + throw refusal; + }, + identities: async () => { + ran.push("identities"); + return 2; + }, + }), + ).rejects.toBe(refusal); + expect(ran).toEqual(["plugins", "identities"]); +}); + +test("an identity failure still retires plugin connections, and is rethrown", async () => { + const failure = new Error("vault down"); + const ran: string[] = []; + await expect( + retireOwnedAccounts({ + plugins: async () => { + ran.push("plugins"); + return { retired: 1 }; + }, + identities: async () => { + ran.push("identities"); + throw failure; + }, + }), + ).rejects.toBe(failure); + expect(ran).toEqual(["plugins", "identities"]); +}); + +test("both failing surfaces both", async () => { + const first = new Error("plugins"); + const second = new Error("identities"); + const error = await retireOwnedAccounts({ + plugins: async () => { + throw first; + }, + identities: async () => { + throw second; + }, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([first, second]); +}); + +test("both succeeding counts both", async () => { + expect( + await retireOwnedAccounts({ + plugins: async () => ({ retired: 2 }), + identities: async () => 3, + }), + ).toEqual({ retired: 5 }); }); From 8264d2c45381348c6c1c65b73c78f54966d9a60c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:27:52 -0300 Subject: [PATCH 036/101] fix(server): keep, lock and own identity link credentials Re-linking an identity without a credentialId used to revoke and null its stored token, so a GitHub re-sign-in that minted no new token killed the working one. Now an omitted credentialId (or handle) keeps what the link has; a different credential replaces the old one and revokes it; only unlink clears a credential. An explicit null handle clears it. A credential belongs to one link: a new partial unique index identity_links_credential_idx (credential_id where not null) backs a check that refuses a credential already held by another link, so unlinking, replacing or retiring one link can no longer revoke another link's token. The unique violation maps to the same IdentityInputError. The credential row is now locked live (select ... where revoked_at is null for update) inside the link's transaction, after the advisory lock and link-row locks (retireIdentityLinks' order), instead of an unlocked isLive check a concurrent revoke could race. A malformed credentialId is refused by shape before any query, so Postgres never echoes it back. Replacing a person's older same-realm link no longer revokes the credential when the new link is taking that same credential. writeLink is module-private now. Call sites: - linkVerified (store.ts) -> writeLink - redeemChallenge (store.ts) -> writeLink, with no credential - retire.ts only names writeLink's advisory key in comments; no import linkVerified callers: server/tests/identity-store.integration.test.ts only (no production caller yet). Migration 0052 (branch-only) regenerated to include the new index. --- server/drizzle/0052_identity_links.sql | 3 +- server/drizzle/meta/0052_snapshot.json | 18 +- server/drizzle/meta/_journal.json | 2 +- server/src/db/schema/identity.ts | 5 + server/src/identity/store.ts | 212 +++++++++++++----- .../tests/identity-store.integration.test.ts | 89 +++++++- 6 files changed, 261 insertions(+), 68 deletions(-) diff --git a/server/drizzle/0052_identity_links.sql b/server/drizzle/0052_identity_links.sql index b0495c777..c2a6140d2 100644 --- a/server/drizzle/0052_identity_links.sql +++ b/server/drizzle/0052_identity_links.sql @@ -25,4 +25,5 @@ ALTER TABLE "identity_links" ADD CONSTRAINT "identity_links_credential_id_creden CREATE INDEX "identity_link_challenges_expiry_idx" ON "identity_link_challenges" USING btree ("expires_at");--> statement-breakpoint CREATE INDEX "identity_link_challenges_user_idx" ON "identity_link_challenges" USING btree ("user_id","provider");--> statement-breakpoint CREATE UNIQUE INDEX "identity_links_identity_idx" ON "identity_links" USING btree ("provider","realm","subject");--> statement-breakpoint -CREATE UNIQUE INDEX "identity_links_user_realm_idx" ON "identity_links" USING btree ("user_id","provider","realm"); \ No newline at end of file +CREATE UNIQUE INDEX "identity_links_user_realm_idx" ON "identity_links" USING btree ("user_id","provider","realm");--> statement-breakpoint +CREATE UNIQUE INDEX "identity_links_credential_idx" ON "identity_links" USING btree ("credential_id") WHERE "identity_links"."credential_id" IS NOT NULL; \ No newline at end of file diff --git a/server/drizzle/meta/0052_snapshot.json b/server/drizzle/meta/0052_snapshot.json index 81b3e9634..203d7789a 100644 --- a/server/drizzle/meta/0052_snapshot.json +++ b/server/drizzle/meta/0052_snapshot.json @@ -1,5 +1,5 @@ { - "id": "54122071-85f2-4f2b-a125-703090a744a2", + "id": "a5c96b02-0eca-4def-bcc4-590cdb66fb28", "prevId": "b4bb328d-1308-403f-a2ac-f8a291aaabc5", "version": "7", "dialect": "postgresql", @@ -9113,6 +9113,22 @@ "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": { diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 3e53b2e19..3a967466c 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -369,7 +369,7 @@ { "idx": 52, "version": "7", - "when": 1791472331083, + "when": 1791473240143, "tag": "0052_identity_links", "breakpoints": true } diff --git a/server/src/db/schema/identity.ts b/server/src/db/schema/identity.ts index 6d58e9521..2a96dff91 100644 --- a/server/src/db/schema/identity.ts +++ b/server/src/db/schema/identity.ts @@ -50,6 +50,11 @@ export const identityLinks = pgTable( table.provider, table.realm, ), + // One link per credential: unlinking, replacing or retiring a link revokes its credential, so a + // shared one would kill the other link's token. + uniqueIndex("identity_links_credential_idx") + .on(table.credentialId) + .where(sql`${table.credentialId} IS NOT NULL`), check( "identity_links_verified_by_check", sql`${table.verifiedBy} IN ('challenge', 'oauth')`, diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index df83ff55d..161ae033f 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -1,8 +1,12 @@ import { createHash, randomUUID } from "node:crypto"; -import { and, eq, gt, lt, sql } from "drizzle-orm"; +import { and, eq, gt, isNull, lt, notInArray, sql } from "drizzle-orm"; import { CredentialRefusedError, type CredentialStore } from "../credentials"; import type { Database } from "../db/client"; -import { identityLinkChallenges, identityLinks } from "../db/schema"; +import { + credentials as credentialRows, + identityLinkChallenges, + identityLinks, +} from "../db/schema"; import { acceptsMethod, isIdentityProvider, parseIdentity } from "./providers"; import { type Identity, @@ -17,7 +21,6 @@ import { type Transaction = Parameters[0]>[0]; type Revoke = Pick; -type Vault = Pick; const identityMatches = (identity: Identity) => and( @@ -80,8 +83,51 @@ async function revokeQuietly( } } +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const CREDENTIAL_UNAVAILABLE = "The credential for this link is not available."; + /** - * Link `identity` to `userId` inside `transaction`. + * Claim `credentialId` for the link being written, inside its transaction: the credential row is + * locked live (`for update`, so a concurrent revoke waits for this link to commit or sees it), and + * it must not already belong to a link other than `own` (the identity's own link and the person's + * older link in the realm, which this write replaces). The partial unique index + * identity_links_credential_idx is the backstop. + */ +async function claimCredential( + transaction: Transaction, + credentialId: string, + own: string[], +) { + const [live] = await transaction + .select({ id: credentialRows.id }) + .from(credentialRows) + .where( + and( + eq(credentialRows.id, credentialId), + isNull(credentialRows.revokedAt), + ), + ) + .for("update"); + if (!live) throw new IdentityInputError(CREDENTIAL_UNAVAILABLE); + const [taken] = await transaction + .select({ id: identityLinks.id }) + .from(identityLinks) + .where( + own.length + ? and( + eq(identityLinks.credentialId, credentialId), + notInArray(identityLinks.id, own), + ) + : eq(identityLinks.credentialId, credentialId), + ) + .limit(1); + if (taken) throw new IdentityInputError(CREDENTIAL_UNAVAILABLE); +} + +/** + * Link `identity` to `userId` inside `transaction`. Module-private: only `linkVerified` (which + * validates the proof first) and `redeemChallenge` (no credential) call it. * * Two races are handled. Two people racing for one identity: the identity row is locked first, and a * racing insert surfaces as a unique violation on the identity index, which is a refusal @@ -89,11 +135,47 @@ async function revokeQuietly( * per-person-per-realm advisory lock taken before any row lock, so the later link sees the earlier * one as `previous` and replaces it. * - * Under the lock: linked to somebody else: refused. Linked to this person: updated in place. - * Otherwise this person's older link in the same realm, if any, is removed with its credential, and - * the new link inserted. + * Locks are taken advisory lock, then link rows, then the credential row: the order + * retireIdentityLinks uses, so the two cannot deadlock. + * + * Under the lock: linked to somebody else: refused. Linked to this person: re-verified. Otherwise + * this person's older link in the same realm, if any, is removed and the new link inserted. + * + * Credential and handle: `undefined` keeps what the link has; a new credential replaces the old + * one, which is revoked; there is no clearing a credential here (unlink does that). An explicit + * null handle clears it. A replaced older link's credential is revoked unless the new link is + * taking that same credential. */ -export async function writeLink( +async function writeLink( + transaction: Transaction, + credentials: Revoke, + identity: Identity, + userId: string, + proof: { + method: LinkMethod; + handle?: string | null; + credentialId?: string; + }, +): Promise { + try { + return await writeLinkLocked( + transaction, + credentials, + identity, + userId, + proof, + ); + } catch (error) { + const constraint = uniqueViolationConstraint(error); + if (constraint === "identity_links_identity_idx") + throw new IdentityConflictError(); + if (constraint === "identity_links_credential_idx") + throw new IdentityInputError(CREDENTIAL_UNAVAILABLE); + throw error; + } +} + +async function writeLinkLocked( transaction: Transaction, credentials: Revoke, identity: Identity, @@ -101,7 +183,7 @@ export async function writeLink( proof: { method: LinkMethod; handle?: string | null; - credentialId?: string | null; + credentialId?: string; }, ): Promise { await transaction.execute( @@ -115,14 +197,17 @@ export async function writeLink( if (existing && existing.userId !== userId) throw new IdentityConflictError(); const now = new Date(); if (existing) { - if (existing.credentialId && existing.credentialId !== proof.credentialId) + if (proof.credentialId) + await claimCredential(transaction, proof.credentialId, [existing.id]); + const credentialId = proof.credentialId ?? existing.credentialId; + if (existing.credentialId && existing.credentialId !== credentialId) await revokeQuietly(credentials, existing.credentialId, transaction); const [updated] = await transaction .update(identityLinks) .set({ - handle: proof.handle ?? existing.handle, + handle: proof.handle === undefined ? existing.handle : proof.handle, verifiedBy: proof.method, - credentialId: proof.credentialId ?? null, + credentialId, status: "active", updatedAt: now, }) @@ -144,42 +229,42 @@ export async function writeLink( ), ) .for("update"); + if (proof.credentialId) + await claimCredential( + transaction, + proof.credentialId, + previous ? [previous.id] : [], + ); if (previous) { await transaction .delete(identityLinks) .where(eq(identityLinks.id, previous.id)); - await revokeQuietly(credentials, previous.credentialId, transaction); - } - try { - const [inserted] = await transaction - .insert(identityLinks) - .values({ - id: randomUUID(), - provider: identity.provider, - realm: identity.realm, - subject: identity.subject, - userId, - handle: proof.handle ?? null, - verifiedBy: proof.method, - credentialId: proof.credentialId ?? null, - status: "active", - createdAt: now, - updatedAt: now, - }) - .returning(); - return toLink( - inserted as typeof identityLinks.$inferSelect, - identity.provider, - ); - } catch (error) { - if (uniqueViolationConstraint(error) === "identity_links_identity_idx") - throw new IdentityConflictError(); - throw error; + // The new link may be taking this very credential: then it is moved, not revoked. + if (previous.credentialId !== proof.credentialId) + await revokeQuietly(credentials, previous.credentialId, transaction); } + const [inserted] = await transaction + .insert(identityLinks) + .values({ + id: randomUUID(), + provider: identity.provider, + realm: identity.realm, + subject: identity.subject, + userId, + handle: proof.handle ?? null, + verifiedBy: proof.method, + credentialId: proof.credentialId ?? null, + status: "active", + createdAt: now, + updatedAt: now, + }) + .returning(); + return toLink( + inserted as typeof identityLinks.$inferSelect, + identity.provider, + ); } -const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - /** Refuses anything that is not a code OpenBot could have issued before it reaches a query. */ function codeHash(code: string) { if (typeof code !== "string" || !UUID.test(code)) @@ -187,7 +272,7 @@ function codeHash(code: string) { return createHash("sha256").update(code.toLowerCase()).digest("hex"); } -export function createIdentityStore(database: Database, credentials: Vault) { +export function createIdentityStore(database: Database, credentials: Revoke) { return { async linkedUser( value: Identity, @@ -236,11 +321,15 @@ export function createIdentityStore(database: Database, credentials: Vault) { /** * Link `value` to `userId` on proof the caller has already verified. * - * A `credentialId`, when given, must name a live credential: it is checked in the link's own - * transaction, so a missing or revoked id is refused rather than stored as a token the link - * does not have. A "challenge" link proves control by code and carries no credential, so it - * refuses one. An "oauth" link is not required to carry one: GitHub sign-in may link before - * a token is stored. + * A `credentialId`, when given, must be a UUID naming a live credential that no other link + * holds: it is checked and locked in the link's own transaction, so a malformed, missing, + * revoked or already-owned id is refused rather than stored as a token the link does not + * have. Omitting it keeps the credential an existing link already has (a re-sign-in that mints + * no token must not kill the working one); a different id replaces it and revokes the old one. + * Only unlink clears a credential. `handle` likewise: omitted keeps, null clears. + * + * A "challenge" link proves control by code and carries no credential, so it refuses one. An + * "oauth" link is not required to carry one: GitHub sign-in may link before a token is stored. */ async linkVerified( value: Identity, @@ -248,7 +337,7 @@ export function createIdentityStore(database: Database, credentials: Vault) { proof: { method: LinkMethod; handle?: string | null; - credentialId?: string | null; + credentialId?: string; }, ): Promise { const identity = parseIdentity(value); @@ -257,20 +346,25 @@ export function createIdentityStore(database: Database, credentials: Vault) { throw new IdentityInputError( "This provider does not accept that kind of proof.", ); - if (proof.credentialId && proof.method === "challenge") + if (proof.credentialId != null && proof.method === "challenge") throw new IdentityInputError( "A challenge link does not carry a credential.", ); - return database.transaction(async (transaction) => { - if ( - proof.credentialId && - !(await credentials.isLive(proof.credentialId, transaction)) - ) - throw new IdentityInputError( - "The credential for this link is not available.", - ); - return writeLink(transaction, credentials, identity, userId, proof); - }); + // Shape first, so a malformed id never reaches Postgres (and its error never echoes it). + if ( + proof.credentialId != null && + (typeof proof.credentialId !== "string" || + !UUID.test(proof.credentialId)) + ) + throw new IdentityInputError(CREDENTIAL_UNAVAILABLE); + // Lowercase: uuid columns read back lowercase, and keep/move compare ids as strings. + const credentialId = proof.credentialId?.toLowerCase() ?? undefined; + return database.transaction((transaction) => + writeLink(transaction, credentials, identity, userId, { + ...proof, + credentialId, + }), + ); }, async markNeedsReconnect(linkId: string): Promise { diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index ddf94cd51..1a7b7d80a 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -148,13 +148,90 @@ test("concurrent links by one person in one realm: the later replaces the earlie expect(mine).toHaveLength(1); const [remaining] = mine; expect([linkA.id, linkB.id]).toContain(remaining?.id); - const live = [first, second].filter((id) => id === remaining?.credentialId); - expect(live).toHaveLength(1); - const liveness = await Promise.all( - [first, second].map((id) => vault.isLive(id)), - ); - expect(liveness.filter(Boolean)).toHaveLength(1); + expect([first, second]).toContain(remaining?.credentialId as string); + const loser = remaining?.credentialId === first ? second : first; expect(await vault.isLive(remaining?.credentialId as string)).toBe(true); + expect(await vault.isLive(loser)).toBe(false); +}); + +test("relinking without a credentialId keeps the stored token live and on the link", async () => { + const person = user(); + const identity = github(); + const token = await credential(); + await store.linkVerified(identity, person, { + method: "oauth", + handle: "dana", + credentialId: token, + }); + const link = await store.linkVerified(identity, person, { method: "oauth" }); + expect(link.credentialId).toBe(token); + expect(link.handle).toBe("dana"); + expect(await vault.isLive(token)).toBe(true); +}); + +test("relinking with an explicit null handle clears it", async () => { + const person = user(); + const identity = github(); + await store.linkVerified(identity, person, { + method: "oauth", + handle: "dana", + }); + const link = await store.linkVerified(identity, person, { + method: "oauth", + handle: null, + }); + expect(link.handle).toBeNull(); +}); + +test("a credential already on another link is refused and stays live", async () => { + const token = await credential(); + const owner = await store.linkVerified(github(), user(), { + method: "oauth", + credentialId: token, + }); + const identity = github(); + await expect( + store.linkVerified(identity, user(), { + method: "oauth", + credentialId: token, + }), + ).rejects.toThrow(IdentityInputError); + expect(await store.linkedUser(identity)).toBeNull(); + expect(await vault.isLive(token)).toBe(true); + const [still] = await database + .select({ credentialId: identityLinks.credentialId }) + .from(identityLinks) + .where(eq(identityLinks.id, owner.id)); + expect(still?.credentialId).toBe(token); +}); + +test("a credentialId that is not a UUID is refused before any query", async () => { + const identity = github(); + const attempt = store.linkVerified(identity, user(), { + method: "oauth", + credentialId: "not-a-uuid", + }); + await expect(attempt).rejects.toThrow(IdentityInputError); + await expect(attempt).rejects.not.toThrow(/not-a-uuid/); + expect(await store.linkedUser(identity)).toBeNull(); +}); + +test("replacing a person's older link while moving its credential to the new one keeps it live", async () => { + const person = user(); + const old = github(); + const token = await credential(); + await store.linkVerified(old, person, { + method: "oauth", + credentialId: token, + }); + const replacement = github(); + const link = await store.linkVerified(replacement, person, { + method: "oauth", + credentialId: token, + }); + expect(await store.linkedUser(old)).toBeNull(); + expect(link.credentialId).toBe(token); + expect(await vault.isLive(token)).toBe(true); }); test("markNeedsReconnect is visible to linkedUser; relinking makes it active again", async () => { From 0541aaae994c42d83ad4bcc3d9547e8e3e0c315e Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:29:03 -0300 Subject: [PATCH 037/101] fix(server): one live link code per person and provider --- server/drizzle/0052_identity_links.sql | 2 +- server/drizzle/meta/0052_snapshot.json | 8 +++---- server/drizzle/meta/_journal.json | 2 +- server/src/db/schema/identity.ts | 6 ++++- server/src/identity/store.ts | 21 +++++++++-------- .../identity-challenges.integration.test.ts | 23 +++++++++++++++++++ 6 files changed, 46 insertions(+), 16 deletions(-) diff --git a/server/drizzle/0052_identity_links.sql b/server/drizzle/0052_identity_links.sql index c2a6140d2..95b1fc4a4 100644 --- a/server/drizzle/0052_identity_links.sql +++ b/server/drizzle/0052_identity_links.sql @@ -23,7 +23,7 @@ CREATE TABLE "identity_links" ( --> statement-breakpoint ALTER TABLE "identity_links" ADD CONSTRAINT "identity_links_credential_id_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."credentials"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint CREATE INDEX "identity_link_challenges_expiry_idx" ON "identity_link_challenges" USING btree ("expires_at");--> statement-breakpoint -CREATE INDEX "identity_link_challenges_user_idx" ON "identity_link_challenges" USING btree ("user_id","provider");--> statement-breakpoint +CREATE UNIQUE INDEX "identity_link_challenges_user_provider_idx" ON "identity_link_challenges" USING btree ("user_id","provider");--> statement-breakpoint CREATE UNIQUE INDEX "identity_links_identity_idx" ON "identity_links" USING btree ("provider","realm","subject");--> statement-breakpoint CREATE UNIQUE INDEX "identity_links_user_realm_idx" ON "identity_links" USING btree ("user_id","provider","realm");--> statement-breakpoint CREATE UNIQUE INDEX "identity_links_credential_idx" ON "identity_links" USING btree ("credential_id") WHERE "identity_links"."credential_id" IS NOT NULL; \ No newline at end of file diff --git a/server/drizzle/meta/0052_snapshot.json b/server/drizzle/meta/0052_snapshot.json index 203d7789a..bc1c9f44c 100644 --- a/server/drizzle/meta/0052_snapshot.json +++ b/server/drizzle/meta/0052_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a5c96b02-0eca-4def-bcc4-590cdb66fb28", + "id": "f5896210-489f-45e4-a318-4056d4d88cf3", "prevId": "b4bb328d-1308-403f-a2ac-f8a291aaabc5", "version": "7", "dialect": "postgresql", @@ -8956,8 +8956,8 @@ "method": "btree", "with": {} }, - "identity_link_challenges_user_idx": { - "name": "identity_link_challenges_user_idx", + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", "columns": [ { "expression": "user_id", @@ -8972,7 +8972,7 @@ "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 3a967466c..0124a41d0 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -369,7 +369,7 @@ { "idx": 52, "version": "7", - "when": 1791473240143, + "when": 1791473326630, "tag": "0052_identity_links", "breakpoints": true } diff --git a/server/src/db/schema/identity.ts b/server/src/db/schema/identity.ts index 2a96dff91..952d4626f 100644 --- a/server/src/db/schema/identity.ts +++ b/server/src/db/schema/identity.ts @@ -81,6 +81,10 @@ export const identityLinkChallenges = pgTable( }, (table) => [ index("identity_link_challenges_expiry_idx").on(table.expiresAt), - index("identity_link_challenges_user_idx").on(table.userId, table.provider), + // One live code per person per provider, enforced here rather than by the code that issues them. + uniqueIndex("identity_link_challenges_user_provider_idx").on( + table.userId, + table.provider, + ), ], ); diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index 161ae033f..db8ae7ed8 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -407,15 +407,6 @@ export function createIdentityStore(database: Database, credentials: Revoke) { ); const code = randomUUID(); return database.transaction(async (transaction) => { - // One live code per person per provider: a new one replaces any earlier one. - await transaction - .delete(identityLinkChallenges) - .where( - and( - eq(identityLinkChallenges.userId, userId), - eq(identityLinkChallenges.provider, provider), - ), - ); // No sweep exists, so codes that were never sent would otherwise accumulate. await transaction .delete(identityLinkChallenges) @@ -433,6 +424,18 @@ export function createIdentityStore(database: Database, credentials: Revoke) { userId, expiresAt: sql`clock_timestamp() + interval '10 minutes'`, }) + // One live code per person per provider, enforced by the unique index: a new code + // replaces any earlier one, even when two requests race. + .onConflictDoUpdate({ + target: [ + identityLinkChallenges.userId, + identityLinkChallenges.provider, + ], + set: { + tokenHash: sql`excluded.token_hash`, + expiresAt: sql`excluded.expires_at`, + }, + }) .returning({ expiresAt: identityLinkChallenges.expiresAt }); if (!challenge) throw new IdentityLinkError(); return { code, expiresAt: challenge.expiresAt }; diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts index e13c5b780..689368f3b 100644 --- a/server/tests/identity-challenges.integration.test.ts +++ b/server/tests/identity-challenges.integration.test.ts @@ -228,3 +228,26 @@ test("redeeming a second code in the same realm replaces the person's older link expect(await store.linkedUser(first)).toBeNull(); expect((await store.linkedUser(second))?.userId).toBe(owner); }); + +test("concurrent requests still leave one live code per person and provider", async () => { + const owner = user(); + const issued = await Promise.all( + Array.from({ length: 10 }, () => store.issueChallenge(owner, "slack")), + ); + const rows = await database + .select() + .from(identityLinkChallenges) + .where(eq(identityLinkChallenges.userId, owner)); + expect(rows).toHaveLength(1); + const live = issued.filter(({ code }) => hashOf(code) === rows[0]?.tokenHash); + expect(live).toHaveLength(1); + for (const { code } of issued) { + if (code === live[0]?.code) continue; + await expect(store.redeemChallenge(code, slack())).rejects.toBeInstanceOf( + IdentityLinkError, + ); + } + expect( + (await store.redeemChallenge(live[0]?.code ?? "", slack())).userId, + ).toBe(owner); +}); From 12482a8f774b8577ff49b628e4377e952f0cd0f5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:29:50 -0300 Subject: [PATCH 038/101] fix(server): audit the link that was actually removed unlink now returns the removed link's provider (read by DELETE ... RETURNING in the same transaction) or null, and the DELETE route audits that instead of a separate identitiesFor lookup that could miss a concurrently removed link or a provider outside the registry. The comment about retries is corrected: the audit is written after the unlink commits, so a failed write fails the request with the link already gone, as for mcp.account_disconnected. unlink call sites: server/src/identity/routes.ts (DELETE /links/:id), server/tests/identity-routes.test.ts (fake store), server/tests/identity-store.integration.test.ts. --- server/src/identity/routes.ts | 8 +++----- server/src/identity/store.ts | 15 +++++++++++---- server/tests/identity-routes.test.ts | 18 ++++++++++++++++-- .../tests/identity-store.integration.test.ts | 8 +++++--- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index a0066c86c..b95359a34 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -47,16 +47,14 @@ export function identityRoutes( app.delete("/links/:id", async (context) => { const actorId = context.var.actor.id; const id = context.req.param("id"); - const link = (await live.identitiesFor(actorId)).find( - (candidate) => candidate.id === id, - ); const removed = await live.unlink(actorId, id); if (!removed) return context.json({ error: "Linked account not found." }, 404); /* * After the removal, and not caught: the same order and the same failure as the connected-account * disconnect (`mcp.account_disconnected`), where an audit write that throws fails the request. - * Repeating it is safe because the link is already gone and answers 404. + * The audit is written after the unlink commits, so such a failure leaves the link already gone + * and the request failed; a retry answers 404 and writes no audit row. * * Names the link and its provider only. The subject, realm and credential id stay out of the * trail, as they stay out of every response here. @@ -67,7 +65,7 @@ export function identityRoutes( targetType: "identity_link", targetId: id, actorUserId: actorId, - payload: { actor: actorId, provider: link?.provider }, + payload: { actor: actorId, provider: removed.provider }, }); } return context.body(null, 204); diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index db8ae7ed8..7f3ede92f 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -374,17 +374,24 @@ export function createIdentityStore(database: Database, credentials: Revoke) { .where(eq(identityLinks.id, linkId)); }, - async unlink(userId: string, linkId: string): Promise { + /** The removed link's provider as stored, or null when the asker has no such link. */ + async unlink( + userId: string, + linkId: string, + ): Promise<{ provider: string } | null> { return database.transaction(async (transaction) => { const [removed] = await transaction .delete(identityLinks) .where( and(eq(identityLinks.id, linkId), eq(identityLinks.userId, userId)), ) - .returning({ credentialId: identityLinks.credentialId }); - if (!removed) return false; + .returning({ + credentialId: identityLinks.credentialId, + provider: identityLinks.provider, + }); + if (!removed) return null; await revokeQuietly(credentials, removed.credentialId, transaction); - return true; + return { provider: removed.provider }; }); }, diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 07c3c8d14..ef133dde4 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -30,7 +30,7 @@ const link: IdentityLink = { updatedAt: new Date("2026-10-08T00:00:00Z"), }; -function fixture() { +function fixture(removedProvider = "github") { const calls: string[] = []; const events: AuditEventInput[] = []; const app = identityRoutes( @@ -39,7 +39,9 @@ function fixture() { identitiesFor: async (userId) => (userId === "person" ? [link] : []), unlink: async (userId, id) => { calls.push(`unlink:${userId}:${id}`); - return userId === "person" && id === "link-1"; + return userId === "person" && id === "link-1" + ? { provider: removedProvider } + : null; }, }, { insert: async (event) => void events.push(event) }, @@ -130,3 +132,15 @@ test("a 404 DELETE records nothing", async () => { await f.app.request("/links/someone-elses", { method: "DELETE" }); expect(f.events).toEqual([]); }); + +test("the audit names the provider the store removed, even one outside the registry", async () => { + const f = fixture("retired-provider"); + expect( + (await f.app.request("/links/link-1", { method: "DELETE" })).status, + ).toBe(204); + expect(f.events).toHaveLength(1); + expect(f.events[0]?.payload).toEqual({ + actor: "person", + provider: "retired-provider", + }); +}); diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index 1a7b7d80a..212e8d68b 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -252,12 +252,14 @@ test("unlink removes only the asker's own link and revokes its credential", asyn method: "oauth", credentialId: token, }); - expect(await store.unlink(user(), link.id)).toBe(false); + expect(await store.unlink(user(), link.id)).toBeNull(); expect(await store.linkedUser(identity)).not.toBeNull(); - expect(await store.unlink(person, link.id)).toBe(true); + expect(await store.unlink(person, link.id)).toEqual({ + provider: "github", + }); expect(await store.linkedUser(identity)).toBeNull(); expect(await vault.isLive(token)).toBe(false); - expect(await store.unlink(person, link.id)).toBe(false); + expect(await store.unlink(person, link.id)).toBeNull(); }); test("a link outlives its user: nothing cascades from users", async () => { From 12be7b95ed1c88af973ca91165208d735a3995bf Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:31:20 -0300 Subject: [PATCH 039/101] fix(server): mark the linked-accounts 503 as not configured --- server/src/identity/routes.ts | 12 +++++++++++- server/tests/identity-routes.test.ts | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index b95359a34..bb9f30b90 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -23,8 +23,18 @@ export function identityRoutes( }); app.use("*", requireUser); if (!store) { + /* + * The code is what tells the settings page "this deployment has no identity store" apart from a + * proxy or platform 503, which must surface as an error rather than hide the section. + */ app.all("*", (context) => - context.json({ error: "Linked accounts are not available." }, 503), + context.json( + { + error: "Linked accounts are not available.", + code: "identity_unavailable", + }, + 503, + ), ); return app; } diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index ef133dde4..bb85b209f 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -100,7 +100,15 @@ test("responses are not cached", async () => { test("without a store every route is 503", async () => { const app = identityRoutes(signedIn); - expect((await app.request("/links")).status).toBe(503); + const response = await app.request("/links"); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Linked accounts are not available.", + code: "identity_unavailable", + }); + const removal = await app.request("/links/x", { method: "DELETE" }); + expect(removal.status).toBe(503); + expect((await removal.json()).code).toBe("identity_unavailable"); }); test("auth runs before the missing-store 503 and responses are never cached", async () => { From 901f5976bc4316d5672170da89e3418aee14cc54 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:31:21 -0300 Subject: [PATCH 040/101] fix(app): per-row disconnect and honest linked-account errors --- .../settings/linked-accounts.test.tsx | 51 +++++++++- .../components/settings/linked-accounts.tsx | 99 ++++++++++--------- app/src/lib/identity/mutations.ts | 31 ++++-- app/src/lib/identity/queries.ts | 24 +++-- 4 files changed, 141 insertions(+), 64 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index 29bc89ade..a1c8d948c 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; +import { unlinkOutcome } from "@/lib/identity/mutations"; import { linksFromResponse } from "@/lib/identity/queries"; import { linkedAccountDescription, providerIcon } from "./linked-accounts"; @@ -48,14 +49,34 @@ test("providers map to their own icon with a neutral fallback", () => { expect(providerIcon("gitlab")).toBe(IconLink); }); -test("a 503 means the feature is absent, so no links", async () => { +test("the not-configured 503 means the feature is absent, so no links", async () => { const response = Response.json( - { error: "Linked accounts are not available." }, + { + error: "Linked accounts are not available.", + code: "identity_unavailable", + }, { status: 503 }, ); expect(await linksFromResponse(response)).toEqual([]); }); +test("any other 503 is an outage and throws its message", async () => { + const response = Response.json( + { error: "Service Unavailable" }, + { status: 503 }, + ); + await expect(linksFromResponse(response)).rejects.toThrow( + "Service Unavailable", + ); +}); + +test("a 503 with no JSON body throws the fallback", async () => { + const response = new Response("upstream down", { status: 503 }); + await expect(linksFromResponse(response)).rejects.toThrow( + "Could not load your linked accounts", + ); +}); + test("other failures still throw the server message", async () => { const response = Response.json({ error: "boom" }, { status: 500 }); await expect(linksFromResponse(response)).rejects.toThrow("boom"); @@ -65,3 +86,29 @@ test("a success unwraps the links", async () => { const links = [{ ...base, handle: "dana", status: "active" as const }]; expect(await linksFromResponse(Response.json({ links }))).toEqual(links); }); + +test("a 204 disconnect succeeds", async () => { + await expect( + unlinkOutcome(new Response(null, { status: 204 })), + ).resolves.toBeUndefined(); +}); + +test("a 404 disconnect means the link is already gone, which is success", async () => { + await expect( + unlinkOutcome( + Response.json({ error: "Linked account not found." }, { status: 404 }), + ), + ).resolves.toBeUndefined(); +}); + +test("any other disconnect failure throws the server message", async () => { + await expect( + unlinkOutcome(Response.json({ error: "boom" }, { status: 500 })), + ).rejects.toThrow("boom"); +}); + +test("a disconnect failure without a message throws the fallback", async () => { + await expect( + unlinkOutcome(new Response("nope", { status: 502 })), + ).rejects.toThrow("The account could not be disconnected"); +}); diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index 0ddaf63c2..a386e4ff8 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -48,64 +48,69 @@ export function providerIcon(provider: string) { */ export function LinkedAccountsSection() { const links = useQuery(linkedAccountsQueryOptions()); - const unlink = useMutation(unlinkMutationOptions(queryClient)); - if (links.isPending) return null; + // Rows win over the error: a failed background refetch must not replace a list already on screen. + if (links.data) { + // Deliberately hidden when nothing is linked, or when this deployment has no identity store. + if (links.data.length === 0) return null; + return ( + + + {links.data.map((account, index) => ( +
+ {index > 0 ? : null} + +
+ ))} +
+
+ ); + } if (links.error) { return (

- Your linked accounts could not be loaded. + Your linked accounts could not be loaded: {links.error.message}

); } - const failedTitle = links.data.find( - (account) => account.id === unlink.variables, - )?.title; - // Deliberately hidden when nothing is linked, or when this deployment has no identity store. - if (links.data.length === 0) return null; + return null; +} + +/** + * One linked account with its own disconnect, so a second click elsewhere neither re-enables this + * row mid-request nor overwrites its error. + */ +function LinkedAccountRow({ account }: { account: LinkedAccount }) { + const unlink = useMutation(unlinkMutationOptions(queryClient)); + const Icon = providerIcon(account.provider); return ( - - - {links.data.map((account, index) => { - const Icon = providerIcon(account.provider); - return ( -
- {index > 0 ? : null} - - - - - - {account.title} - - {linkedAccountDescription(account)} - - - - - - -
- ); - })} -
+ <> + + + + + + {account.title} + {linkedAccountDescription(account)} + + + + + {unlink.error ? ( -

- Could not disconnect {failedTitle ?? "that account"}:{" "} - {unlink.error.message} +

+ Could not disconnect {account.title}: {unlink.error.message}

) : null} -
+ ); } diff --git a/app/src/lib/identity/mutations.ts b/app/src/lib/identity/mutations.ts index 51e4a8a4c..c7d5557eb 100644 --- a/app/src/lib/identity/mutations.ts +++ b/app/src/lib/identity/mutations.ts @@ -1,15 +1,32 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query"; -import { client } from "@/lib/client"; +import { tryClient } from "@/lib/client"; import { identityKeys } from "./queries"; +const UNLINK_FALLBACK = "The account could not be disconnected"; + +/** + * A 404 means the link is already gone — removed in another tab, or by a retry after a failed audit + * write — which is what the person asked for, so it is success rather than an error. + */ +export async function unlinkOutcome(response: Response): Promise { + if (response.ok || response.status === 404) return; + const message = await response + .json() + .then((body: { error?: unknown }) => + typeof body?.error === "string" ? body.error : undefined, + ) + .catch(() => undefined); + throw new Error(message ?? UNLINK_FALLBACK); +} + export function unlinkMutationOptions(queryClient: QueryClient) { return mutationOptions({ - mutationFn: async (id: string): Promise => { - await client(`/api/identity/links/${encodeURIComponent(id)}`, { - method: "DELETE", - fallback: "The account could not be disconnected", - }); - }, + mutationFn: async (id: string): Promise => + unlinkOutcome( + await tryClient(`/api/identity/links/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + ), onSettled: () => queryClient.invalidateQueries({ queryKey: identityKeys.links() }), }); diff --git a/app/src/lib/identity/queries.ts b/app/src/lib/identity/queries.ts index 95b8449cf..91dcb8a27 100644 --- a/app/src/lib/identity/queries.ts +++ b/app/src/lib/identity/queries.ts @@ -17,20 +17,28 @@ export const identityKeys = { const LOAD_FALLBACK = "Could not load your linked accounts"; +/** The machine-readable code on the server's own "no identity store here" 503. */ +export const IDENTITY_UNAVAILABLE = "identity_unavailable"; + /** - * A deployment without an identity store answers 503 "not available". That is the feature being - * absent, not the server breaking, so it reads as no links and the section hides itself. + * A deployment without an identity store answers 503 with `code: "identity_unavailable"`. That is + * the feature being absent, not the server breaking, so it reads as no links and the section hides + * itself. Any other failure, including a 503 from a proxy or the platform, throws so it is shown. */ export async function linksFromResponse( response: Response, ): Promise { - if (response.status === 503) return []; if (!response.ok) { - const message = await response - .json() - .then((body: { error?: string }) => body.error) - .catch(() => undefined); - throw new Error(message ?? LOAD_FALLBACK); + const body = (await response.json().catch(() => null)) as { + error?: unknown; + code?: unknown; + } | null; + if (response.status === 503 && body?.code === IDENTITY_UNAVAILABLE) { + return []; + } + throw new Error( + typeof body?.error === "string" ? body.error : LOAD_FALLBACK, + ); } const body = (await response.json().catch(() => null)) as { links?: LinkedAccount[]; From cd1d7aa675869765f53a873ed59ccc3c5713b884 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:46:13 -0300 Subject: [PATCH 041/101] fix(server): tag a missing linked account --- server/src/identity/routes.ts | 9 ++++++++- server/tests/identity-routes.test.ts | 14 +++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index bb9f30b90..8f6734e48 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -58,8 +58,15 @@ export function identityRoutes( const actorId = context.var.actor.id; const id = context.req.param("id"); const removed = await live.unlink(actorId, id); + /* + * The code is what tells the settings page "this link is already gone" apart from a proxy, + * missing-route or SPA-fallback 404, which must surface as a failed disconnect. + */ if (!removed) - return context.json({ error: "Linked account not found." }, 404); + return context.json( + { error: "Linked account not found.", code: "identity_link_not_found" }, + 404, + ); /* * After the removal, and not caught: the same order and the same failure as the connected-account * disconnect (`mcp.account_disconnected`), where an audit write that throws fails the request. diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index bb85b209f..f2fd0f945 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -74,11 +74,15 @@ test("DELETE of the asker's own link is 204", async () => { expect(f.calls).toEqual(["unlink:person:link-1"]); }); -test("DELETE of another person's link is 404", async () => { - expect( - (await fixture().app.request("/links/someone-elses", { method: "DELETE" })) - .status, - ).toBe(404); +test("DELETE of another person's link is a tagged 404", async () => { + const response = await fixture().app.request("/links/someone-elses", { + method: "DELETE", + }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: "Linked account not found.", + code: "identity_link_not_found", + }); }); test("the chat-started confirmation routes are gone", async () => { From 9dc92601e1c4f2baf09c3d4d0381ad0ebffdd8c2 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:46:26 -0300 Subject: [PATCH 042/101] fix(app): only the server's own not-found counts as disconnected --- .../settings/linked-accounts.test.tsx | 24 ++++++++++++++-- app/src/lib/identity/mutations.ts | 28 ++++++++++++------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index a1c8d948c..bea42fbe3 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -93,14 +93,34 @@ test("a 204 disconnect succeeds", async () => { ).resolves.toBeUndefined(); }); -test("a 404 disconnect means the link is already gone, which is success", async () => { +test("the server's own 404 means the link is already gone, which is success", async () => { await expect( unlinkOutcome( - Response.json({ error: "Linked account not found." }, { status: 404 }), + Response.json( + { error: "Linked account not found.", code: "identity_link_not_found" }, + { status: 404 }, + ), ), ).resolves.toBeUndefined(); }); +test("a 404 without the server's code is a failed disconnect", async () => { + await expect( + unlinkOutcome(Response.json({ error: "Not Found" }, { status: 404 })), + ).rejects.toThrow("Not Found"); +}); + +test("an HTML 404 from a proxy or SPA fallback throws the fallback", async () => { + await expect( + unlinkOutcome( + new Response("", { + status: 404, + headers: { "content-type": "text/html" }, + }), + ), + ).rejects.toThrow("The account could not be disconnected"); +}); + test("any other disconnect failure throws the server message", async () => { await expect( unlinkOutcome(Response.json({ error: "boom" }, { status: 500 })), diff --git a/app/src/lib/identity/mutations.ts b/app/src/lib/identity/mutations.ts index c7d5557eb..28c212c2a 100644 --- a/app/src/lib/identity/mutations.ts +++ b/app/src/lib/identity/mutations.ts @@ -4,19 +4,27 @@ import { identityKeys } from "./queries"; const UNLINK_FALLBACK = "The account could not be disconnected"; +/** The machine-readable code on the server's own "no such link" 404. */ +export const IDENTITY_LINK_NOT_FOUND = "identity_link_not_found"; + /** - * A 404 means the link is already gone — removed in another tab, or by a retry after a failed audit - * write — which is what the person asked for, so it is success rather than an error. + * The server's own 404, tagged `code: "identity_link_not_found"`, means the link is already gone — + * removed in another tab, or by a retry after a failed audit write — which is what the person asked + * for, so it is success rather than an error. Any other 404 (a proxy, a missing route, an SPA + * fallback page) says nothing about the link, so it throws: the credential may still be live. */ export async function unlinkOutcome(response: Response): Promise { - if (response.ok || response.status === 404) return; - const message = await response - .json() - .then((body: { error?: unknown }) => - typeof body?.error === "string" ? body.error : undefined, - ) - .catch(() => undefined); - throw new Error(message ?? UNLINK_FALLBACK); + if (response.ok) return; + const body = (await response.json().catch(() => null)) as { + error?: unknown; + code?: unknown; + } | null; + if (response.status === 404 && body?.code === IDENTITY_LINK_NOT_FOUND) { + return; + } + throw new Error( + typeof body?.error === "string" ? body.error : UNLINK_FALLBACK, + ); } export function unlinkMutationOptions(queryClient: QueryClient) { From 55d234c197a3ed669d4b1873955e3c2f11e73acb Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:46:50 -0300 Subject: [PATCH 043/101] fix(app): show linked-account load errors alongside stale rows --- .../settings/linked-accounts.test.tsx | 45 ++++++++++++++- .../components/settings/linked-accounts.tsx | 55 ++++++++++++------- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index bea42fbe3..650b36ef6 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -2,7 +2,11 @@ import { expect, test } from "bun:test"; import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { unlinkOutcome } from "@/lib/identity/mutations"; import { linksFromResponse } from "@/lib/identity/queries"; -import { linkedAccountDescription, providerIcon } from "./linked-accounts"; +import { + linkedAccountDescription, + linkedAccountsSectionState, + providerIcon, +} from "./linked-accounts"; const base = { id: "1", @@ -87,6 +91,45 @@ test("a success unwraps the links", async () => { expect(await linksFromResponse(Response.json({ links }))).toEqual(links); }); +test("a 200 whose body has no links array throws the fallback", async () => { + await expect( + linksFromResponse(Response.json({ accounts: [] })), + ).rejects.toThrow("Could not load your linked accounts"); + await expect( + linksFromResponse(new Response("", { status: 200 })), + ).rejects.toThrow("Could not load your linked accounts"); +}); + +const row = { ...base, handle: "dana", status: "active" as const }; + +test("the section hides while loading and when nothing is linked", () => { + expect(linkedAccountsSectionState({ data: undefined, error: null })).toBe( + null, + ); + expect(linkedAccountsSectionState({ data: [], error: null })).toBe(null); +}); + +test("rows show on their own when the list loaded", () => { + expect(linkedAccountsSectionState({ data: [row], error: null })).toEqual({ + rows: [row], + error: null, + }); +}); + +test("a failed refetch keeps stale rows on screen and shows the error with them", () => { + expect( + linkedAccountsSectionState({ data: [row], error: new Error("boom") }), + ).toEqual({ rows: [row], error: "boom" }); +}); + +test("an error with no data, or with an empty cached list, still shows", () => { + for (const data of [undefined, []]) { + expect( + linkedAccountsSectionState({ data, error: new Error("boom") }), + ).toEqual({ rows: [], error: "boom" }); + } +}); + test("a 204 disconnect succeeds", async () => { await expect( unlinkOutcome(new Response(null, { status: 204 })), diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index a386e4ff8..2fa665297 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -40,6 +40,26 @@ export function providerIcon(provider: string) { ); } +/** + * What the section draws: nothing, rows, an error, or rows with an error under them. + * + * Hidden while loading and when nothing is linked (or the deployment has no identity store). A + * failed refetch keeps the rows already on screen — stale but visible — and says the list could not + * be refreshed, so a removed account that is still drawn is never shown without that warning. An + * error with no rows to keep, including over a cached empty list, shows on its own. + */ +export function linkedAccountsSectionState({ + data, + error, +}: { + data: LinkedAccount[] | undefined; + error: Error | null; +}): { rows: LinkedAccount[]; error: string | null } | null { + const rows = data ?? []; + if (error) return { rows, error: error.message }; + return rows.length > 0 ? { rows, error: null } : null; +} + /** * Who you are in the chat apps and code hosts this deployment talks to. * @@ -48,33 +68,30 @@ export function providerIcon(provider: string) { */ export function LinkedAccountsSection() { const links = useQuery(linkedAccountsQueryOptions()); - // Rows win over the error: a failed background refetch must not replace a list already on screen. - if (links.data) { - // Deliberately hidden when nothing is linked, or when this deployment has no identity store. - if (links.data.length === 0) return null; - return ( - + const state = linkedAccountsSectionState({ + data: links.data, + error: links.error, + }); + if (!state) return null; + return ( + + {state.rows.length > 0 ? ( - {links.data.map((account, index) => ( + {state.rows.map((account, index) => (
{index > 0 ? : null}
))}
-
- ); - } - if (links.error) { - return ( - -

- Your linked accounts could not be loaded: {links.error.message} + ) : null} + {state.error ? ( +

+ Your linked accounts could not be loaded: {state.error}

-
- ); - } - return null; + ) : null} +
+ ); } /** From 1f3ae8872a0ed39c9ff705426ae00a5d90ad8959 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:46:55 -0300 Subject: [PATCH 044/101] test(server): pin which identity constraint fired --- .../tests/identity-schema.integration.test.ts | 126 +++++++++++++++--- 1 file changed, 106 insertions(+), 20 deletions(-) diff --git a/server/tests/identity-schema.integration.test.ts b/server/tests/identity-schema.integration.test.ts index 847c9f3f2..d8f45e10e 100644 --- a/server/tests/identity-schema.integration.test.ts +++ b/server/tests/identity-schema.integration.test.ts @@ -1,18 +1,52 @@ import { afterAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; +import { createCredentialStore } from "../src/credentials"; import { createDatabase } from "../src/db/client"; -import { identityLinks } from "../src/db/schema"; +import { + credentials, + identityLinkChallenges, + identityLinks, +} from "../src/db/schema"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const credentialStore = createCredentialStore(database); const realm = `test-${randomUUID()}`; +const challengeUsers = [`user-${randomUUID()}`]; +const credentialIds: string[] = []; afterAll(async () => { await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); + await database + .delete(identityLinkChallenges) + .where(inArray(identityLinkChallenges.userId, challengeUsers)); + if (credentialIds.length > 0) + await database + .delete(credentials) + .where(inArray(credentials.id, credentialIds)); await database.$client.close(); }); +/** + * Which constraint a rejected write tripped. Drizzle wraps the driver error, so SQLSTATE and + * constraint name sit on `cause`. A bare `rejects.toThrow()` would pass on any failure at all. + */ +async function violation(promise: Promise) { + const error = await promise.then( + () => { + throw new Error("expected the write to be rejected"); + }, + (caught: unknown) => caught, + ); + const driver = (error as { cause?: unknown }).cause ?? error; + const { errno, constraint } = driver as { + errno?: unknown; + constraint?: unknown; + }; + return { sqlState: errno, constraint }; +} + const row = (subject: string, userId: string) => ({ id: randomUUID(), provider: "slack", @@ -24,29 +58,81 @@ const row = (subject: string, userId: string) => ({ test("an outside account links to one user", async () => { await database.insert(identityLinks).values(row("U1", "user-a")); - await expect( - database.insert(identityLinks).values(row("U1", "user-b")).execute(), - ).rejects.toThrow(); + expect( + await violation( + database.insert(identityLinks).values(row("U1", "user-b")).execute(), + ), + ).toEqual({ sqlState: "23505", constraint: "identity_links_identity_idx" }); }); test("a user has one account per provider and realm", async () => { await database.insert(identityLinks).values(row("U2", "user-c")); - await expect( - database.insert(identityLinks).values(row("U3", "user-c")).execute(), - ).rejects.toThrow(); + expect( + await violation( + database.insert(identityLinks).values(row("U3", "user-c")).execute(), + ), + ).toEqual({ sqlState: "23505", constraint: "identity_links_user_realm_idx" }); +}); + +test("one credential belongs to one link", async () => { + const { id } = await credentialStore.create({ + kind: "connector", + provider: "github-user-token", + keyId: randomUUID(), + metadata: {}, + encryptedValue: "x", + }); + credentialIds.push(id); + await database + .insert(identityLinks) + .values({ ...row("U6", "user-f"), credentialId: id }); + expect( + await violation( + database + .insert(identityLinks) + .values({ ...row("U7", "user-g"), credentialId: id }) + .execute(), + ), + ).toEqual({ sqlState: "23505", constraint: "identity_links_credential_idx" }); +}); + +test("a user has one live link code per provider", async () => { + const userId = challengeUsers[0] as string; + const challenge = () => ({ + tokenHash: randomUUID(), + provider: "slack", + userId, + expiresAt: new Date(Date.now() + 600_000), + }); + await database.insert(identityLinkChallenges).values(challenge()); + expect( + await violation( + database.insert(identityLinkChallenges).values(challenge()).execute(), + ), + ).toEqual({ + sqlState: "23505", + constraint: "identity_link_challenges_user_provider_idx", + }); }); test("verified_by and status are constrained", async () => { - await expect( - database - .insert(identityLinks) - .values({ ...row("U4", "user-d"), verifiedBy: "guess" as never }) - .execute(), - ).rejects.toThrow(); - await expect( - database - .insert(identityLinks) - .values({ ...row("U5", "user-e"), status: "gone" as never }) - .execute(), - ).rejects.toThrow(); + expect( + await violation( + database + .insert(identityLinks) + .values({ ...row("U4", "user-d"), verifiedBy: "guess" as never }) + .execute(), + ), + ).toEqual({ + sqlState: "23514", + constraint: "identity_links_verified_by_check", + }); + expect( + await violation( + database + .insert(identityLinks) + .values({ ...row("U5", "user-e"), status: "gone" as never }) + .execute(), + ), + ).toEqual({ sqlState: "23514", constraint: "identity_links_status_check" }); }); From 2d9cb634e6db1d60e5945a91e6437e34ece70a2a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:46:59 -0300 Subject: [PATCH 045/101] docs: say what replacement and removal do to linked accounts --- docs/configuration.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6918a4a8b..37b73f791 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -435,8 +435,9 @@ A person the directory creates gets the `user` role, or `admin` when their addre `INITIAL_ADMIN_EMAILS`, and still signs in through the company's identity provider: SCIM creates no password. Their directory groups become their OpenBot groups, which per-group capability switches and network policies read. Deactivating or deleting someone in the directory ends their sessions, -deny-lists the address, retires the connector credentials they granted and stops their Bots' -computers. Reactivating them lifts a deny-list entry SCIM wrote, never one an administrator wrote. +deny-lists the address, retires the connector credentials they granted and their linked accounts +(see Linked accounts), and stops their Bots' computers. Reactivating them lifts a deny-list entry +SCIM wrote, never one an administrator wrote. ## One Bot handing work to another @@ -490,16 +491,22 @@ number opted out, later messages show `opted_out` in the delivery history instea OpenBot keeps a record of a person's accounts at outside providers, such as their GitHub account or their Slack user. When a person has any, they appear under **Linked accounts** on `/settings/connected-accounts`, and each can be disconnected there. Disconnecting revokes any token -stored with the link, and is audited. A person with none sees no such section, and a deployment -without an identity store does not show it at all. +stored with the link, and is audited. A person with none sees no such section. -Links are created by the integrations that support them, and none ship enabled yet, so the list is -empty until one is. An integration proves an account in one of two ways: by the provider's own +Links are created by the integrations that support them. Until one is configured, the list stays +empty. An integration proves an account in one of two ways: by the provider's own sign-in, or by a one-time code that OpenBot issues to the signed-in person and that they send from their chat account. Whoever sends the code gets that chat account linked to the person it was issued to, so treat it like a password and do not share it. It is valid for ten minutes and works once, and issuing a new code for the same provider invalidates the earlier one. An outside account links to -one OpenBot user at most. +one OpenBot user at most. Linking a new account at a provider and workspace replaces the person's +earlier link there and revokes its token. + +Removing a person, whether an administrator does it or a SCIM deactivation does, retires their +links. Their unused link codes are deleted, any token stored with a link is revoked, and each link is +marked as needing reconnection. The links are kept, not deleted, so the outside account still +resolves to the removed person and is refused rather than treated as an unlinked guest. Each retired +link is recorded in the audit trail. This is separate from the OpenTag **Reachability** pairing above. That pairing binds a conversation for delivery and uses its own `link ` messages, shown on the **Reachability** screen; it is not From 58c62232d821f1f63e778206fcc4b8fbc3565c84 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:47:09 -0300 Subject: [PATCH 046/101] fix(server): accept only the provider's own token on a link --- server/src/identity/providers.ts | 12 ++++- server/src/identity/store.ts | 36 +++++++++---- server/tests/identity-providers.test.ts | 5 ++ .../tests/identity-store.integration.test.ts | 50 +++++++++++++++++-- 4 files changed, 90 insertions(+), 13 deletions(-) diff --git a/server/src/identity/providers.ts b/server/src/identity/providers.ts index ab2025837..83833cd27 100644 --- a/server/src/identity/providers.ts +++ b/server/src/identity/providers.ts @@ -12,6 +12,12 @@ type ProviderSpec = { methods: readonly LinkMethod[]; /** For challenge providers: what the person sends from their account there, carrying the code. */ instruction?: (code: string) => string; + /** + * The vault `provider` of the connector credential this provider's links hold (its user token). + * A link may only carry a live connector credential with exactly this provider; a provider + * without one stores no token, so its links refuse any credential. + */ + credentialProvider?: string; }; export const PROVIDERS: Readonly> = @@ -22,7 +28,11 @@ export const PROVIDERS: Readonly> = instruction: (code) => `Send this message to the OpenBot bot in Slack: link ${code}`, }, - github: { title: "GitHub", methods: ["oauth"] }, + github: { + title: "GitHub", + methods: ["oauth"], + credentialProvider: "github-user-token", + }, }); const REALM_MAX = 512; diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index 7f3ede92f..b688f2ddf 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -7,7 +7,12 @@ import { identityLinkChallenges, identityLinks, } from "../db/schema"; -import { acceptsMethod, isIdentityProvider, parseIdentity } from "./providers"; +import { + acceptsMethod, + isIdentityProvider, + PROVIDERS, + parseIdentity, +} from "./providers"; import { type Identity, IdentityConflictError, @@ -89,16 +94,21 @@ const CREDENTIAL_UNAVAILABLE = "The credential for this link is not available."; /** * Claim `credentialId` for the link being written, inside its transaction: the credential row is - * locked live (`for update`, so a concurrent revoke waits for this link to commit or sees it), and - * it must not already belong to a link other than `own` (the identity's own link and the person's - * older link in the realm, which this write replaces). The partial unique index - * identity_links_credential_idx is the backstop. + * locked live (`for update`, so a concurrent revoke waits for this link to commit or sees it), it + * must be this provider's own user token (kind "connector", vault provider equal to the registry's + * `credentialProvider`) so a link can never adopt, and a later unlink or replace never revoke, some + * other secret such as an operator key, and it must not already belong to a link other than `own` + * (the identity's own link and the person's older link in the realm, which this write replaces). + * The partial unique index identity_links_credential_idx is the backstop. */ async function claimCredential( transaction: Transaction, + provider: IdentityProvider, credentialId: string, own: string[], ) { + const credentialProvider = PROVIDERS[provider].credentialProvider; + if (!credentialProvider) throw new IdentityInputError(CREDENTIAL_UNAVAILABLE); const [live] = await transaction .select({ id: credentialRows.id }) .from(credentialRows) @@ -106,6 +116,8 @@ async function claimCredential( and( eq(credentialRows.id, credentialId), isNull(credentialRows.revokedAt), + eq(credentialRows.kind, "connector"), + eq(credentialRows.provider, credentialProvider), ), ) .for("update"); @@ -198,7 +210,12 @@ async function writeLinkLocked( const now = new Date(); if (existing) { if (proof.credentialId) - await claimCredential(transaction, proof.credentialId, [existing.id]); + await claimCredential( + transaction, + identity.provider, + proof.credentialId, + [existing.id], + ); const credentialId = proof.credentialId ?? existing.credentialId; if (existing.credentialId && existing.credentialId !== credentialId) await revokeQuietly(credentials, existing.credentialId, transaction); @@ -232,6 +249,7 @@ async function writeLinkLocked( if (proof.credentialId) await claimCredential( transaction, + identity.provider, proof.credentialId, previous ? [previous.id] : [], ); @@ -321,9 +339,9 @@ export function createIdentityStore(database: Database, credentials: Revoke) { /** * Link `value` to `userId` on proof the caller has already verified. * - * A `credentialId`, when given, must be a UUID naming a live credential that no other link - * holds: it is checked and locked in the link's own transaction, so a malformed, missing, - * revoked or already-owned id is refused rather than stored as a token the link does not + * A `credentialId`, when given, must be a UUID naming a live credential that is this + * provider's own user token (see `credentialProvider`) and that no other link holds: it is checked and locked in the link's own transaction, so a malformed, missing, + * revoked, foreign or already-owned id is refused rather than stored as a token the link does not * have. Omitting it keeps the credential an existing link already has (a re-sign-in that mints * no token must not kill the working one); a different id replaces it and revokes the old one. * Only unlink clears a credential. `handle` likewise: omitted keeps, null clears. diff --git a/server/tests/identity-providers.test.ts b/server/tests/identity-providers.test.ts index 91cb9ef7a..5dda798cf 100644 --- a/server/tests/identity-providers.test.ts +++ b/server/tests/identity-providers.test.ts @@ -119,3 +119,8 @@ test("slackRealm refuses a realm longer than parseIdentity accepts", () => { }), ).toThrow(IdentityInputError); }); + +test("only a provider that stores a token names the credential its links may hold", () => { + expect(PROVIDERS.github.credentialProvider).toBe("github-user-token"); + expect(PROVIDERS.slack.credentialProvider).toBeUndefined(); +}); diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index 212e8d68b..615abc097 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -34,10 +34,13 @@ const github = (subject = randomUUID()): Identity => ({ }); const user = () => `user-${randomUUID()}`; -async function credential() { +async function credential( + kind: "connector" | "model" = "connector", + provider = "github-user-token", +) { const { id } = await vault.create({ - kind: "connector", - provider: "github-user-token", + kind, + provider, keyId: randomUUID(), metadata: {}, encryptedValue: "not-a-real-ciphertext", @@ -328,3 +331,44 @@ test("a challenge link refuses a credential", async () => { ).rejects.toThrow(IdentityInputError); expect(await store.linkedUser(identity)).toBeNull(); }); + +test("a live credential of another kind is refused and stays live", async () => { + const identity = github(); + const operatorKey = await credential("model"); + await expect( + store.linkVerified(identity, user(), { + method: "oauth", + credentialId: operatorKey, + }), + ).rejects.toThrow("The credential for this link is not available."); + expect(await store.linkedUser(identity)).toBeNull(); + expect(await vault.isLive(operatorKey)).toBe(true); +}); + +test("a connector credential for another provider is refused and stays live", async () => { + const identity = github(); + const person = user(); + const other = await credential("connector", "slack-bot-token"); + await expect( + store.linkVerified(identity, person, { + method: "oauth", + credentialId: other, + }), + ).rejects.toThrow("The credential for this link is not available."); + expect(await store.linkedUser(identity)).toBeNull(); + expect(await vault.isLive(other)).toBe(true); + // Relinking an existing link with it is refused too, and the link's own token survives. + const own = await credential(); + await store.linkVerified(identity, person, { + method: "oauth", + credentialId: own, + }); + await expect( + store.linkVerified(identity, person, { + method: "oauth", + credentialId: other, + }), + ).rejects.toThrow(IdentityInputError); + expect(await vault.isLive(other)).toBe(true); + expect(await vault.isLive(own)).toBe(true); +}); From f842c7652b5d6d9250eee5314b52167b3a008404 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:47:09 -0300 Subject: [PATCH 047/101] fix(app): fall back for unknown provider icons --- app/src/components/settings/linked-accounts.test.tsx | 11 +++++++++++ app/src/components/settings/linked-accounts.tsx | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index 650b36ef6..f8b5c1ece 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -53,6 +53,17 @@ test("providers map to their own icon with a neutral fallback", () => { expect(providerIcon("gitlab")).toBe(IconLink); }); +test("Object prototype keys are unknown providers, not icons", () => { + for (const key of [ + "constructor", + "toString", + "hasOwnProperty", + "__proto__", + ]) { + expect(providerIcon(key)).toBe(IconLink); + } +}); + test("the not-configured 503 means the feature is absent, so no links", async () => { const response = Response.json( { diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index 2fa665297..df68a1947 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -34,10 +34,10 @@ const providerIcons = { /** A provider this build has no mark for gets a neutral link icon rather than someone else's logo. */ export function providerIcon(provider: string) { - return ( - (providerIcons as Record)[provider] ?? - IconLink - ); + // Own keys only: "constructor" or "toString" would otherwise find an Object prototype member. + return Object.hasOwn(providerIcons, provider) + ? providerIcons[provider as keyof typeof providerIcons] + : IconLink; } /** From c464bbcac48e0444949c1f3220bf34cdfcdeb6bd Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:47:39 -0300 Subject: [PATCH 048/101] fix(server): treat link handles the same on every path --- server/src/identity/store.ts | 25 +++++++++-- .../identity-challenges.integration.test.ts | 42 +++++++++++++++++++ .../tests/identity-store.integration.test.ts | 24 +++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index b688f2ddf..c42ebf535 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -88,6 +88,21 @@ async function revokeQuietly( } } +const HANDLE_MAX = 256; + +/** + * One rule for a display handle on every path: omitted (`undefined`) keeps what the link has, + * null clears it, a string is trimmed and capped at 256 code points (never splitting a surrogate + * pair), and a blank one becomes null. Anything else is refused. + */ +function normalizeHandle(handle: unknown): string | null | undefined { + if (handle === undefined || handle === null) return handle; + if (typeof handle !== "string") throw new IdentityInputError(); + const trimmed = handle.trim(); + if (!trimmed) return null; + return Array.from(trimmed).slice(0, HANDLE_MAX).join(""); +} + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const CREDENTIAL_UNAVAILABLE = "The credential for this link is not available."; @@ -377,9 +392,11 @@ export function createIdentityStore(database: Database, credentials: Revoke) { throw new IdentityInputError(CREDENTIAL_UNAVAILABLE); // Lowercase: uuid columns read back lowercase, and keep/move compare ids as strings. const credentialId = proof.credentialId?.toLowerCase() ?? undefined; + const handle = normalizeHandle(proof.handle); return database.transaction((transaction) => writeLink(transaction, credentials, identity, userId, { - ...proof, + method: proof.method, + handle, credentialId, }), ); @@ -470,14 +487,16 @@ export function createIdentityStore(database: Database, credentials: Revoke) { /** * Link `value`, the chat account the code arrived from, to the user the code was issued to. * Consumption and link creation share one transaction: a refused link leaves the code unused. + * `handle` follows the same rule as linkVerified: omitted keeps the stored one, null clears. */ async redeemChallenge( code: string, value: Identity, - handle: string | null = null, + handle?: string | null, ): Promise { const hash = codeHash(code); const identity = parseIdentity(value); + const normalized = normalizeHandle(handle); return database.transaction(async (transaction) => { const [challenge] = await transaction .select() @@ -499,7 +518,7 @@ export function createIdentityStore(database: Database, credentials: Revoke) { if (!consumed) throw new IdentityLinkError(); return writeLink(transaction, credentials, identity, consumed.userId, { method: "challenge", - handle: handle?.slice(0, 256) ?? null, + handle: normalized, }); }); }, diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts index 689368f3b..415ca833d 100644 --- a/server/tests/identity-challenges.integration.test.ts +++ b/server/tests/identity-challenges.integration.test.ts @@ -251,3 +251,45 @@ test("concurrent requests still leave one live code per person and provider", as (await store.redeemChallenge(live[0]?.code ?? "", slack())).userId, ).toBe(owner); }); + +test("redeeming again without a handle keeps the stored one; an explicit null clears it", async () => { + const owner = user(); + const sender = slack(); + const issue = async () => (await store.issueChallenge(owner, "slack")).code; + await store.redeemChallenge(await issue(), sender, "dana"); + expect((await store.redeemChallenge(await issue(), sender)).handle).toBe( + "dana", + ); + expect( + (await store.redeemChallenge(await issue(), sender, undefined)).handle, + ).toBe("dana"); + expect( + (await store.redeemChallenge(await issue(), sender, null)).handle, + ).toBeNull(); +}); + +test("a redeemed handle is capped at 256 code points without splitting a pair", async () => { + const owner = user(); + const link = await store.redeemChallenge( + (await store.issueChallenge(owner, "slack")).code, + slack(), + "\u{1F600}".repeat(300), + ); + expect(Array.from(link.handle ?? "")).toHaveLength(256); + expect(link.handle).toBe("\u{1F600}".repeat(256)); +}); + +test("a blank redeemed handle is stored as none, and a non-string one is refused", async () => { + const owner = user(); + const link = await store.redeemChallenge( + (await store.issueChallenge(owner, "slack")).code, + slack(), + " ", + ); + expect(link.handle).toBeNull(); + const { code } = await store.issueChallenge(owner, "slack"); + await expect( + store.redeemChallenge(code, slack(), 42 as unknown as string), + ).rejects.toThrow(IdentityInputError); + expect(await rowFor(code)).toBeDefined(); +}); diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index 615abc097..29ae6e0c4 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -372,3 +372,27 @@ test("a connector credential for another provider is refused and stays live", as expect(await vault.isLive(other)).toBe(true); expect(await vault.isLive(own)).toBe(true); }); + +test("a linked handle is capped at 256 code points like a redeemed one", async () => { + const link = await store.linkVerified(github(), user(), { + method: "oauth", + handle: `${"a".repeat(255)}\u{1F600}\u{1F600}`, + }); + expect(link.handle).toBe(`${"a".repeat(255)}\u{1F600}`); +}); + +test("a blank linked handle is stored as none, and a non-string one is refused", async () => { + const blank = await store.linkVerified(github(), user(), { + method: "oauth", + handle: " ", + }); + expect(blank.handle).toBeNull(); + const identity = github(); + await expect( + store.linkVerified(identity, user(), { + method: "oauth", + handle: { name: "dana" } as unknown as string, + }), + ).rejects.toThrow(IdentityInputError); + expect(await store.linkedUser(identity)).toBeNull(); +}); From 5a30d2364f4982a3f250c486af956c67a3e16127 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:48:23 -0300 Subject: [PATCH 049/101] fix(server): serialize link codes with person removal --- server/src/identity/store.ts | 47 ++++++++++++++++++- .../identity-challenges.integration.test.ts | 31 +++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index c42ebf535..9f2aa5b37 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -26,6 +26,34 @@ import { type Transaction = Parameters[0]>[0]; type Revoke = Pick; +type Locker = Pick; + +/** + * The per-person identity lock: a transaction-scoped advisory lock on `userId`. issueChallenge + * takes it, and person removal (retireIdentityLinks) is meant to take the same key, so a link code + * is issued wholly before a removal (which then deletes it) or wholly after, never alongside. + */ +export async function identityUserLock(transaction: Locker, userId: string) { + await transaction.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`identity-user\u001f${userId}`}, 0))`, + ); +} + +/** + * The per-person-per-realm lock writeLink takes before any row lock, serializing one person's + * links in one `(provider, realm)`. `provider` is text so a caller reading stored rows, whose + * provider may be one this build does not know, can take the same key. + */ +export async function identityRealmLock( + transaction: Locker, + userId: string, + provider: string, + realm: string, +) { + await transaction.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`${userId}\u001f${provider}\u001f${realm}`}, 0))`, + ); +} const identityMatches = (identity: Identity) => and( @@ -213,8 +241,11 @@ async function writeLinkLocked( credentialId?: string; }, ): Promise { - await transaction.execute( - sql`select pg_advisory_xact_lock(hashtextextended(${`${userId}\u001f${identity.provider}\u001f${identity.realm}`}, 0))`, + await identityRealmLock( + transaction, + userId, + identity.provider, + identity.realm, ); const [existing] = await transaction .select() @@ -307,6 +338,12 @@ function codeHash(code: string) { export function createIdentityStore(database: Database, credentials: Revoke) { return { + /** + * Who `value` is linked to, and the link's status. A removed person's links persist by design + * (retirement marks them needs_reconnect rather than deleting them, so the outside account + * keeps resolving to the removed person and is refused rather than becoming a guest), so + * callers must also check the returned user is still an active person before acting for them. + */ async linkedUser( value: Identity, ): Promise<{ userId: string; status: LinkStatus } | null> { @@ -434,6 +471,11 @@ export function createIdentityStore(database: Database, credentials: Revoke) { * A one-time code for the signed-in `userId`. The link is made when the code arrives from the * person's account at `provider` (redeemChallenge), and always links to the user it was issued * to, so a code started by somebody else can never attach their account to this person. + * + * Issued under identityUserLock, the lock person removal takes, so a code is never issued + * alongside a removal: one issued before it is deleted by it. A code issued after a removal + * (by a caller that skipped its session check) can still link, but only to the removed person, + * whom linkedUser's callers must refuse. */ async issueChallenge( userId: string, @@ -449,6 +491,7 @@ export function createIdentityStore(database: Database, credentials: Revoke) { ); const code = randomUUID(); return database.transaction(async (transaction) => { + await identityUserLock(transaction, userId); // No sweep exists, so codes that were never sent would otherwise accumulate. await transaction .delete(identityLinkChallenges) diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts index 415ca833d..e90eed4cb 100644 --- a/server/tests/identity-challenges.integration.test.ts +++ b/server/tests/identity-challenges.integration.test.ts @@ -4,7 +4,7 @@ import { eq, inArray, sql } from "drizzle-orm"; import { createCredentialStore } from "../src/credentials"; import { createDatabase } from "../src/db/client"; import { identityLinkChallenges, identityLinks } from "../src/db/schema"; -import { createIdentityStore } from "../src/identity/store"; +import { createIdentityStore, identityUserLock } from "../src/identity/store"; import { type Identity, IdentityConflictError, @@ -293,3 +293,32 @@ test("a blank redeemed handle is stored as none, and a non-string one is refused ).rejects.toThrow(IdentityInputError); expect(await rowFor(code)).toBeDefined(); }); + +test("issuing a code waits for the person's identity lock, which removal holds", async () => { + const owner = user(); + let release = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + let locked = () => {}; + const isLocked = new Promise((resolve) => { + locked = resolve; + }); + const holder = database.transaction(async (transaction) => { + await identityUserLock(transaction, owner); + locked(); + await released; + }); + await isLocked; + let issued = false; + const issuing = store.issueChallenge(owner, "slack").then((result) => { + issued = true; + return result; + }); + await Bun.sleep(200); + expect(issued).toBe(false); + release(); + await holder; + const { code } = await issuing; + expect(await rowFor(code)).toBeDefined(); +}); From 347f11bda310341ec5ea4637858ab32266b63092 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:49:11 -0300 Subject: [PATCH 050/101] refactor(server): tidy identity store edges --- server/src/identity/store.ts | 24 ++++++++++----- .../identity-challenges.integration.test.ts | 17 +++++++++++ .../tests/identity-store.integration.test.ts | 29 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index 9f2aa5b37..ad473bc14 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -131,6 +131,12 @@ function normalizeHandle(handle: unknown): string | null | undefined { return Array.from(trimmed).slice(0, HANDLE_MAX).join(""); } +/** A user id is an opaque, non-blank string with no surrounding whitespace, never coerced. */ +function requireUserId(userId: unknown): asserts userId is string { + if (typeof userId !== "string" || !userId.trim() || userId !== userId.trim()) + throw new IdentityInputError(); +} + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const CREDENTIAL_UNAVAILABLE = "The credential for this link is not available."; @@ -140,8 +146,9 @@ const CREDENTIAL_UNAVAILABLE = "The credential for this link is not available."; * locked live (`for update`, so a concurrent revoke waits for this link to commit or sees it), it * must be this provider's own user token (kind "connector", vault provider equal to the registry's * `credentialProvider`) so a link can never adopt, and a later unlink or replace never revoke, some - * other secret such as an operator key, and it must not already belong to a link other than `own` - * (the identity's own link and the person's older link in the realm, which this write replaces). + * other secret such as an operator key, and it must not already belong to any link outside `own` + * (the one link this write updates or replaces: the identity's own link on a relink, else the + * person's older link in the realm, if any). * The partial unique index identity_links_credential_idx is the backstop. */ async function claimCredential( @@ -182,7 +189,8 @@ async function claimCredential( /** * Link `identity` to `userId` inside `transaction`. Module-private: only `linkVerified` (which - * validates the proof first) and `redeemChallenge` (no credential) call it. + * checks the proof's method against the provider and the credential id's shape first; the proof + * itself is the caller's to verify) and `redeemChallenge` (no credential) call it. * * Two races are handled. Two people racing for one identity: the identity row is locked first, and a * racing insert surfaces as a unique violation on the identity index, which is a refusal @@ -411,7 +419,7 @@ export function createIdentityStore(database: Database, credentials: Revoke) { }, ): Promise { const identity = parseIdentity(value); - if (!userId.trim()) throw new IdentityInputError(); + requireUserId(userId); if (!acceptsMethod(identity.provider, proof.method)) throw new IdentityInputError( "This provider does not accept that kind of proof.", @@ -481,7 +489,7 @@ export function createIdentityStore(database: Database, credentials: Revoke) { userId: string, provider: IdentityProvider, ): Promise<{ code: string; expiresAt: Date }> { - if (!userId.trim()) throw new IdentityInputError(); + requireUserId(userId); if ( !isIdentityProvider(provider) || !acceptsMethod(provider, "challenge") @@ -492,7 +500,8 @@ export function createIdentityStore(database: Database, credentials: Revoke) { const code = randomUUID(); return database.transaction(async (transaction) => { await identityUserLock(transaction, userId); - // No sweep exists, so codes that were never sent would otherwise accumulate. + // Nothing else deletes an expired code (a person has at most one per provider, but one + // never redeemed stays forever), so each issue clears those over a day stale. await transaction .delete(identityLinkChallenges) .where( @@ -522,7 +531,8 @@ export function createIdentityStore(database: Database, credentials: Revoke) { }, }) .returning({ expiresAt: identityLinkChallenges.expiresAt }); - if (!challenge) throw new IdentityLinkError(); + // An upsert always returns its row; this only narrows the type. + if (!challenge) throw new Error("The link code was not stored."); return { code, expiresAt: challenge.expiresAt }; }); }, diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts index e90eed4cb..bdf790b64 100644 --- a/server/tests/identity-challenges.integration.test.ts +++ b/server/tests/identity-challenges.integration.test.ts @@ -322,3 +322,20 @@ test("issuing a code waits for the person's identity lock, which removal holds", const { code } = await issuing; expect(await rowFor(code)).toBeDefined(); }); + +test("an uppercase code redeems", async () => { + const owner = user(); + const { code } = await store.issueChallenge(owner, "slack"); + expect( + (await store.redeemChallenge(code.toUpperCase(), slack())).userId, + ).toBe(owner); +}); + +test.each([42, undefined, " user-padded", "user-padded "])( + "issuing for a userId of %p is refused as input", + async (userId) => { + await expect( + store.issueChallenge(userId as unknown as string, "slack"), + ).rejects.toThrow(IdentityInputError); + }, +); diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts index 29ae6e0c4..0a5d3c7a7 100644 --- a/server/tests/identity-store.integration.test.ts +++ b/server/tests/identity-store.integration.test.ts @@ -396,3 +396,32 @@ test("a blank linked handle is stored as none, and a non-string one is refused", ).rejects.toThrow(IdentityInputError); expect(await store.linkedUser(identity)).toBeNull(); }); + +test("an uppercase credentialId is kept on relink, not revoked as a different token", async () => { + const person = user(); + const identity = github(); + const token = await credential(); + await store.linkVerified(identity, person, { + method: "oauth", + credentialId: token.toUpperCase(), + }); + const link = await store.linkVerified(identity, person, { + method: "oauth", + credentialId: token.toUpperCase(), + }); + expect(link.credentialId).toBe(token); + expect(await vault.isLive(token)).toBe(true); +}); + +test.each([42, null, " user-padded", "user-padded "])( + "a userId of %p is refused as input", + async (userId) => { + const identity = github(); + await expect( + store.linkVerified(identity, userId as unknown as string, { + method: "oauth", + }), + ).rejects.toThrow(IdentityInputError); + expect(await store.linkedUser(identity)).toBeNull(); + }, +); From 09bb63737158369fdcd9a8e7e777d95c2435107c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:51:12 -0300 Subject: [PATCH 051/101] refactor(server): share identity lock keys with retirement --- server/src/identity/retire.ts | 32 ++++++++--------- .../tests/identity-retire.integration.test.ts | 36 ++++++++++++++++++- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/server/src/identity/retire.ts b/server/src/identity/retire.ts index 7b8fc1588..577d0a07a 100644 --- a/server/src/identity/retire.ts +++ b/server/src/identity/retire.ts @@ -1,8 +1,9 @@ -import { asc, eq, sql } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { type AuditStore, recordAuditEvent } from "../audit"; import { CredentialRefusedError, type CredentialStore } from "../credentials"; import type { Database } from "../db/client"; import { identityLinkChallenges, identityLinks } from "../db/schema"; +import { identityRealmLock, identityUserLock } from "./store"; /** * Retires a removed person's identity links. @@ -19,15 +20,17 @@ import { identityLinkChallenges, identityLinks } from "../db/schema"; * order `retireConnectionsFor` uses): actor `by`, target the link id, and the provider, the reason * and whether a credential was revoked. Never the subject, realm or credential id. * - * Concurrency. The challenges are deleted first: a `redeemChallenge` in flight holds its challenge - * row, so the delete waits for it to commit and the link it wrote is then seen below. Then, for - * every realm the person has a link in, the exact advisory key `writeLink` takes for - * `(userId, provider, realm)` is taken — in sorted order, before any row lock, the order `writeLink` - * uses — so a concurrent re-link in a known realm finishes first or waits for this. The residual - * window: a `writeLink` that is not a challenge redemption (an OAuth link) into a realm this person - * had no link in, still uncommitted when the realms are read, commits an active link after this - * returns. The person's sessions are already gone by then, so that needs a callback already past - * its session check. + * Concurrency. Locks are taken in this order: `identityUserLock`, the lock `issueChallenge` takes, so + * no code is issued alongside the removal; then the challenges are deleted, which waits for a + * `redeemChallenge` in flight (it holds its challenge row) to commit, so the link it wrote is seen + * below; then `identityRealmLock` for every `(provider, realm)` the person has a link in, in sorted + * order (writeLink takes the one key for the realm it writes, so sorting only keeps two retirements + * of one person from deadlocking each other); then the link rows. A writeLink into one of those realms + * that is already running finishes first and is retired here. One that arrives later waits, and once + * this commits it goes ahead: it re-links that realm for the removed person, active again, so + * refusing it is left to linkedUser's callers, which refuse a removed person. The same holds for a + * writeLink (an OAuth link) into a realm the person had no link in when the realms were read. Both + * need a callback already past its session check, since the person's sessions are gone by then. * * Returns how many links changed; a second call returns 0 and writes no audit rows. */ @@ -39,6 +42,7 @@ export async function retireIdentityLinks( by: string, ): Promise { const retired = await database.transaction(async (transaction) => { + await identityUserLock(transaction, userId); await transaction .delete(identityLinkChallenges) .where(eq(identityLinkChallenges.userId, userId)); @@ -51,12 +55,8 @@ export async function retireIdentityLinks( .from(identityLinks) .where(eq(identityLinks.userId, userId)) .orderBy(asc(identityLinks.provider), asc(identityLinks.realm)); - for (const { provider, realm } of realms) { - // writeLink's key, character for character (server/src/identity/store.ts). - await transaction.execute( - sql`select pg_advisory_xact_lock(hashtextextended(${`${userId}\u001f${provider}\u001f${realm}`}, 0))`, - ); - } + for (const { provider, realm } of realms) + await identityRealmLock(transaction, userId, provider, realm); const links = await transaction .select({ diff --git a/server/tests/identity-retire.integration.test.ts b/server/tests/identity-retire.integration.test.ts index c7311196f..dcb813094 100644 --- a/server/tests/identity-retire.integration.test.ts +++ b/server/tests/identity-retire.integration.test.ts @@ -13,7 +13,7 @@ import { retireIdentityLinks, retireOwnedAccounts, } from "../src/identity/retire"; -import { createIdentityStore } from "../src/identity/store"; +import { createIdentityStore, identityUserLock } from "../src/identity/store"; import { IdentityLinkError } from "../src/identity/types"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; @@ -178,6 +178,40 @@ test("a pending challenge cannot be redeemed after the person is removed", async ).rejects.toBeInstanceOf(IdentityLinkError); }); +test("retirement waits on the per-person identity lock that issuing a code takes", async () => { + const userId = user(); + let release = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + let locked = () => {}; + const isLocked = new Promise((resolve) => { + locked = resolve; + }); + const holder = database.transaction(async (transaction) => { + await identityUserLock(transaction, userId); + locked(); + await released; + }); + await isLocked; + let finished = false; + const retiring = retireIdentityLinks( + database, + vault, + recorder().store, + userId, + ADMIN, + ).then((count) => { + finished = true; + return count; + }); + await Bun.sleep(200); + expect(finished).toBe(false); + release(); + await holder; + expect(await retiring).toBe(0); +}); + test("a plugin refusal still retires identity links, and is rethrown", async () => { const refusal = new Error("composio refused"); const ran: string[] = []; From 56888be9509b4002381d5e76cdec352040b79b24 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:52:06 -0300 Subject: [PATCH 052/101] fix(server): audit linked-account retirement inside its transaction --- server/src/identity/retire.ts | 55 ++++---- server/src/index.ts | 2 +- .../tests/identity-retire.integration.test.ts | 121 ++++++++++++++++-- 3 files changed, 138 insertions(+), 40 deletions(-) diff --git a/server/src/identity/retire.ts b/server/src/identity/retire.ts index 577d0a07a..8f9cf2344 100644 --- a/server/src/identity/retire.ts +++ b/server/src/identity/retire.ts @@ -15,10 +15,14 @@ import { identityRealmLock, identityUserLock } from "./store"; * into an unlinked guest. The person's pending link challenges are deleted, so a code issued before * the removal cannot be redeemed into a fresh active link afterwards. * - * One transaction, so a failure leaves nothing half-retired. One `identity.link_retired` audit row - * per link that changed, written after the commit (the audit store has its own handle — the same - * order `retireConnectionsFor` uses): actor `by`, target the link id, and the provider, the reason - * and whether a credential was revoked. Never the subject, realm or credential id. + * One transaction, the audit rows included: `auditFor` is handed the transaction and returns the + * store that writes into it (`createAuditStore(transaction)`, as approvals do), so a link is retired + * exactly when its `identity.link_retired` row is written. A failure anywhere, an audit row + * included, rolls the whole retirement back and a retry starts over and audits every link; writing + * the rows after the commit instead lost the rest for good when one failed, because the retry found + * every link already retired. One row per link that changed: actor `by`, target the link id, and the + * provider, the reason and whether a credential was revoked. Never the subject, realm or credential + * id. * * Concurrency. Locks are taken in this order: `identityUserLock`, the lock `issueChallenge` takes, so * no code is issued alongside the removal; then the challenges are deleted, which waits for a @@ -37,11 +41,12 @@ import { identityRealmLock, identityUserLock } from "./store"; export async function retireIdentityLinks( database: Database, credentials: Pick, - auditStore: AuditStore, + auditFor: (executor: Database) => AuditStore, userId: string, by: string, ): Promise { - const retired = await database.transaction(async (transaction) => { + return database.transaction(async (transaction) => { + const auditStore = auditFor(transaction as unknown as Database); await identityUserLock(transaction, userId); await transaction .delete(identityLinkChallenges) @@ -70,11 +75,7 @@ export async function retireIdentityLinks( .orderBy(asc(identityLinks.id)) .for("update"); - const changed: { - id: string; - provider: string; - credentialRevoked: boolean; - }[] = []; + let changed = 0; for (const link of links) { if (!link.credentialId && link.status === "needs_reconnect") continue; if (link.credentialId) { @@ -93,30 +94,22 @@ export async function retireIdentityLinks( updatedAt: new Date(), }) .where(eq(identityLinks.id, link.id)); - changed.push({ - id: link.id, - provider: link.provider, - credentialRevoked: Boolean(link.credentialId), + await recordAuditEvent(auditStore, { + eventType: "identity.link_retired", + targetType: "identity_link", + targetId: link.id, + actorUserId: by, + payload: { + actor: by, + provider: link.provider, + reason: "person_removed", + credentialRevoked: Boolean(link.credentialId), + }, }); + changed += 1; } return changed; }); - - for (const link of retired) { - await recordAuditEvent(auditStore, { - eventType: "identity.link_retired", - targetType: "identity_link", - targetId: link.id, - actorUserId: by, - payload: { - actor: by, - provider: link.provider, - reason: "person_removed", - credentialRevoked: link.credentialRevoked, - }, - }); - } - return retired.length; } /** diff --git a/server/src/index.ts b/server/src/index.ts index 1898acb67..c187f8bd2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -412,7 +412,7 @@ const peopleStore = createPeopleStore( retireIdentityLinks( database, credentialStore, - bootAuditStore, + (executor) => createAuditStore(executor), userId, by, ), diff --git a/server/tests/identity-retire.integration.test.ts b/server/tests/identity-retire.integration.test.ts index dcb813094..cb1d75bd5 100644 --- a/server/tests/identity-retire.integration.test.ts +++ b/server/tests/identity-retire.integration.test.ts @@ -1,10 +1,15 @@ import { afterAll, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { eq, inArray } from "drizzle-orm"; -import type { AuditEventInput, AuditStore } from "../src/audit"; +import { eq, inArray, like } from "drizzle-orm"; +import { + type AuditEventInput, + type AuditStore, + createAuditStore, +} from "../src/audit"; import { createCredentialStore } from "../src/credentials"; import { createDatabase } from "../src/db/client"; import { + auditEvents, credentials, identityLinkChallenges, identityLinks, @@ -25,7 +30,9 @@ const users: string[] = []; const ADMIN = "admin-remover"; afterAll(async () => { - await database.delete(identityLinks).where(eq(identityLinks.realm, realm)); + await database + .delete(identityLinks) + .where(like(identityLinks.realm, `${realm}%`)); if (users.length) await database .delete(identityLinkChallenges) @@ -86,7 +93,13 @@ test("retiring a removed person's links revokes their tokens and keeps the rows" const audit = recorder(); expect( - await retireIdentityLinks(database, vault, audit.store, userId, ADMIN), + await retireIdentityLinks( + database, + vault, + () => audit.store, + userId, + ADMIN, + ), ).toBe(1); expect(await vault.isLive(credential.id)).toBe(false); @@ -114,7 +127,13 @@ test("retiring a removed person's links revokes their tokens and keeps the rows" // A second pass changes nothing and records nothing. expect( - await retireIdentityLinks(database, vault, audit.store, userId, ADMIN), + await retireIdentityLinks( + database, + vault, + () => audit.store, + userId, + ADMIN, + ), ).toBe(0); expect(audit.rows).toHaveLength(1); }); @@ -135,7 +154,13 @@ test("a link without a credential is also marked needs_reconnect, and audited wi const audit = recorder(); expect( - await retireIdentityLinks(database, vault, audit.store, userId, ADMIN), + await retireIdentityLinks( + database, + vault, + () => audit.store, + userId, + ADMIN, + ), ).toBe(1); const row = await statusOf(linkId); @@ -157,12 +182,92 @@ test("a link without a credential is also marked needs_reconnect, and audited wi expect(payload).not.toContain(realm); }); +test("an audit row that fails to write rolls the retirement back, so a retry retires and audits every link", async () => { + const userId = user(); + const linkIds = [randomUUID(), randomUUID()]; + for (const id of linkIds) + await database.insert(identityLinks).values({ + id, + provider: "slack", + realm: `${realm}-${id}`, + subject: randomUUID(), + userId, + verifiedBy: "challenge", + status: "active", + }); + let written = 0; + const failing: AuditStore = { + insert: async () => { + written += 1; + if (written === 2) throw new Error("audit store down"); + }, + }; + + await expect( + retireIdentityLinks(database, vault, () => failing, userId, ADMIN), + ).rejects.toThrow("audit store down"); + for (const id of linkIds) expect((await statusOf(id))?.status).toBe("active"); + + const audit = recorder(); + expect( + await retireIdentityLinks( + database, + vault, + () => audit.store, + userId, + ADMIN, + ), + ).toBe(2); + expect(audit.rows.map((row) => row.targetId).sort()).toEqual( + [...linkIds].sort(), + ); + for (const id of linkIds) + expect((await statusOf(id))?.status).toBe("needs_reconnect"); +}); + +test("the audit trail's own store writes the rows inside the retirement's transaction", async () => { + const userId = user(); + const linkId = randomUUID(); + await database.insert(identityLinks).values({ + id: linkId, + provider: "slack", + realm, + subject: randomUUID(), + userId, + verifiedBy: "challenge", + status: "active", + }); + + expect( + await retireIdentityLinks( + database, + vault, + (executor) => createAuditStore(executor), + userId, + ADMIN, + ), + ).toBe(1); + + const rows = await database + .select() + .from(auditEvents) + .where(eq(auditEvents.targetId, linkId)); + expect(rows).toHaveLength(1); + expect(rows[0]?.eventType).toBe("identity.link_retired"); +}); + test("a pending challenge cannot be redeemed after the person is removed", async () => { const userId = user(); const identities = createIdentityStore(database, vault); const { code } = await identities.issueChallenge(userId, "slack"); - await retireIdentityLinks(database, vault, recorder().store, userId, ADMIN); + await retireIdentityLinks( + database, + vault, + () => recorder().store, + userId, + ADMIN, + ); const pending = await database .select() @@ -198,7 +303,7 @@ test("retirement waits on the per-person identity lock that issuing a code takes const retiring = retireIdentityLinks( database, vault, - recorder().store, + () => recorder().store, userId, ADMIN, ).then((count) => { From 7c697ac80769bbf01f06f58160caf8c51c58b9f1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:53:14 -0300 Subject: [PATCH 053/101] fix(server): record linked-account retirement accurately --- server/src/audit.ts | 12 ++- server/src/identity/retire.ts | 55 ++++++++++---- .../tests/identity-retire.integration.test.ts | 73 ++++++++++++++++++- 3 files changed, 119 insertions(+), 21 deletions(-) diff --git a/server/src/audit.ts b/server/src/audit.ts index 5d487905f..36c4ecb33 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -511,11 +511,17 @@ export const auditEventTypes = [ /* A Bot connected to, or disconnected from, a Slack, Teams or SMS conversation. */ "delivery.linked", "delivery.unlinked", - /* A person removing their own linked account at an outside provider (Slack, GitHub). */ + /* + * A person removing their own link to an outside account (Slack, GitHub). The link and its stored + * token go here; nothing is changed at the provider. + */ "identity.unlinked", /* - * A removed person's linked account retired by the removal: marked `needs_reconnect`, its token (if - * any) revoked. Names the link, its provider and the remover only. + * A removed person's linked account retired by the removal: marked `needs_reconnect`, its stored + * token (if any) revoked here. Target the link id; payload `actor` (the remover, not always a user + * id, so never `actorUserId`), `owner` (the removed person), `provider`, `reason` + * ("person_removed") and `credentialRevoked` (whether this removal revoked a live token). Never the + * subject, realm or credential id. */ "identity.link_retired", /* Routines, however they were made: by a person on the screen or by a Bot through its tools. */ diff --git a/server/src/identity/retire.ts b/server/src/identity/retire.ts index 8f9cf2344..b99d71290 100644 --- a/server/src/identity/retire.ts +++ b/server/src/identity/retire.ts @@ -1,10 +1,31 @@ import { asc, eq } from "drizzle-orm"; import { type AuditStore, recordAuditEvent } from "../audit"; -import { CredentialRefusedError, type CredentialStore } from "../credentials"; +import { + type CredentialExecutor, + CredentialRefusedError, + type CredentialStore, +} from "../credentials"; import type { Database } from "../db/client"; import { identityLinkChallenges, identityLinks } from "../db/schema"; import { identityRealmLock, identityUserLock } from "./store"; +/** Revokes a live credential; false when there is none or it was already revoked. */ +async function revokeIfLive( + credentials: Pick, + id: string | null, + executor: CredentialExecutor, +): Promise { + if (!id) return false; + try { + await credentials.revoke(id, executor); + return true; + } catch (error) { + // Already revoked is the outcome wanted, but not this call's doing. + if (error instanceof CredentialRefusedError) return false; + throw error; + } +} + /** * Retires a removed person's identity links. * @@ -20,16 +41,21 @@ import { identityRealmLock, identityUserLock } from "./store"; * exactly when its `identity.link_retired` row is written. A failure anywhere, an audit row * included, rolls the whole retirement back and a retry starts over and audits every link; writing * the rows after the commit instead lost the rest for good when one failed, because the retry found - * every link already retired. One row per link that changed: actor `by`, target the link id, and the - * provider, the reason and whether a credential was revoked. Never the subject, realm or credential - * id. + * every link already retired. + * + * One row per link that changed, shaped like `retireConnectionsFor`'s `mcp.account_disconnected` + * rows: target the link id; payload `actor` (the remover, `by`), `owner` (the removed person), + * `provider`, `reason` ("person_removed") and `credentialRevoked`, true only when this call revoked + * a live credential (one already revoked is detached but not reported as revoked). The remover goes + * in the payload and not in `actorUserId`, because `by` need not be a user id: a SCIM removal passes + * "scim:directory". Never the subject, realm or credential id. * * Concurrency. Locks are taken in this order: `identityUserLock`, the lock `issueChallenge` takes, so * no code is issued alongside the removal; then the challenges are deleted, which waits for a * `redeemChallenge` in flight (it holds its challenge row) to commit, so the link it wrote is seen * below; then `identityRealmLock` for every `(provider, realm)` the person has a link in, in sorted - * order (writeLink takes the one key for the realm it writes, so sorting only keeps two retirements - * of one person from deadlocking each other); then the link rows. A writeLink into one of those realms + * order (defensive: writeLink takes only the one key for the realm it writes, and two retirements of + * one person already queue on the user lock); then the link rows. A writeLink into one of those realms * that is already running finishes first and is retired here. One that arrives later waits, and once * this commits it goes ahead: it re-links that realm for the removed person, active again, so * refusing it is left to linkedUser's callers, which refuse a removed person. The same holds for a @@ -78,14 +104,11 @@ export async function retireIdentityLinks( let changed = 0; for (const link of links) { if (!link.credentialId && link.status === "needs_reconnect") continue; - if (link.credentialId) { - try { - await credentials.revoke(link.credentialId, transaction); - } catch (error) { - // Already revoked is the outcome we wanted. - if (!(error instanceof CredentialRefusedError)) throw error; - } - } + const credentialRevoked = await revokeIfLive( + credentials, + link.credentialId, + transaction, + ); await transaction .update(identityLinks) .set({ @@ -98,12 +121,12 @@ export async function retireIdentityLinks( eventType: "identity.link_retired", targetType: "identity_link", targetId: link.id, - actorUserId: by, payload: { actor: by, + owner: userId, provider: link.provider, reason: "person_removed", - credentialRevoked: Boolean(link.credentialId), + credentialRevoked, }, }); changed += 1; diff --git a/server/tests/identity-retire.integration.test.ts b/server/tests/identity-retire.integration.test.ts index cb1d75bd5..c4cb1ac1f 100644 --- a/server/tests/identity-retire.integration.test.ts +++ b/server/tests/identity-retire.integration.test.ts @@ -113,14 +113,15 @@ test("retiring a removed person's links revokes their tokens and keeps the rows" eventType: "identity.link_retired", targetType: "identity_link", targetId: linkId, - actorUserId: ADMIN, payload: { actor: ADMIN, + owner: userId, provider: "github", reason: "person_removed", credentialRevoked: true, }, }); + expect(audit.rows[0]?.actorUserId).toBeUndefined(); const payload = JSON.stringify(audit.rows[0]?.payload); expect(payload).not.toContain(realm); expect(payload).not.toContain(credential.id); @@ -170,8 +171,9 @@ test("a link without a credential is also marked needs_reconnect, and audited wi expect(audit.rows[0]).toMatchObject({ eventType: "identity.link_retired", targetId: linkId, - actorUserId: ADMIN, payload: { + actor: ADMIN, + owner: userId, provider: "slack", reason: "person_removed", credentialRevoked: false, @@ -182,6 +184,73 @@ test("a link without a credential is also marked needs_reconnect, and audited wi expect(payload).not.toContain(realm); }); +test("a credential revoked before the removal is not reported as revoked by it", async () => { + const userId = user(); + const credential = await vault.create({ + kind: "connector", + provider: "github-user-token", + keyId: randomUUID(), + metadata: {}, + encryptedValue: "x", + }); + createdCredentials.push(credential.id); + await vault.revoke(credential.id); + const linkId = randomUUID(); + await database.insert(identityLinks).values({ + id: linkId, + provider: "github", + realm, + subject: randomUUID(), + userId, + verifiedBy: "oauth", + credentialId: credential.id, + status: "active", + }); + const audit = recorder(); + + expect( + await retireIdentityLinks( + database, + vault, + () => audit.store, + userId, + ADMIN, + ), + ).toBe(1); + + expect((await statusOf(linkId))?.credentialId).toBeNull(); + expect(audit.rows[0]?.payload).toMatchObject({ credentialRevoked: false }); +}); + +test("a directory remover is recorded in the payload only, never as the acting user", async () => { + const userId = user(); + await database.insert(identityLinks).values({ + id: randomUUID(), + provider: "slack", + realm, + subject: randomUUID(), + userId, + verifiedBy: "challenge", + status: "active", + }); + const audit = recorder(); + + await retireIdentityLinks( + database, + vault, + () => audit.store, + userId, + "scim:directory", + ); + + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0]?.actorUserId).toBeUndefined(); + expect(audit.rows[0]?.payload).toMatchObject({ + actor: "scim:directory", + owner: userId, + }); +}); + test("an audit row that fails to write rolls the retirement back, so a retry retires and audits every link", async () => { const userId = user(); const linkIds = [randomUUID(), randomUUID()]; From 927bf12a4e88659c3be19e16ebd108a35c390815 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 12:54:02 -0300 Subject: [PATCH 054/101] fix(server): name both failures when retiring a person's accounts --- server/src/identity/retire.ts | 10 +++++++--- server/src/index.ts | 18 +++++++++--------- .../tests/identity-retire.integration.test.ts | 3 +++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/server/src/identity/retire.ts b/server/src/identity/retire.ts index b99d71290..b43c3c01d 100644 --- a/server/src/identity/retire.ts +++ b/server/src/identity/retire.ts @@ -140,8 +140,9 @@ export async function retireIdentityLinks( * * Both halves always run: the plugin half rethrows on purpose (a broker refusal), and stopping there * left every linked account's token live, with a retry hitting the same refusal first. The failure - * is still thrown afterwards — the first one alone, or an AggregateError of both — so the caller's - * error handling sees it. On success, the count is both halves together. + * is still thrown afterwards — the first one alone, or an AggregateError of both whose message + * names both causes (callers log `String(error)`, which shows the message and not `.errors`) — so + * the caller's error handling sees it. On success, the count is both halves together. */ export async function retireOwnedAccounts(retirers: { plugins: () => Promise<{ retired: number }>; @@ -163,7 +164,10 @@ export async function retireOwnedAccounts(retirers: { if (failures.length > 1) throw new AggregateError( failures, - "Retiring a removed person's accounts failed in more than one place.", + `Retiring a removed person's accounts failed in both halves: plugins: ${messageOf(failures[0])}; linked accounts: ${messageOf(failures[1])}`, ); return { retired }; } + +const messageOf = (error: unknown) => + error instanceof Error ? error.message : String(error); diff --git a/server/src/index.ts b/server/src/index.ts index c187f8bd2..f843ff077 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -394,16 +394,16 @@ const peopleStore = createPeopleStore( database, config.auth?.initialAdminEmails ?? [], /* - * Removing somebody retires the credentials they granted this deployment. + * Removing somebody retires what they connected: their plugin credentials and connections, and + * their linked outside accounts (which keep resolving to them, now refused; the tokens are revoked + * and every link needs reconnecting). Both halves run whichever fails, and the failure is rethrown + * after (see retireOwnedAccounts). * - * A closure rather than the method itself, because the plugin store is built further down: this - * has to exist before `auth` does, and that one needs the vault and the policy. Nothing calls this - * during module initialisation — it runs when an administrator removes somebody, over HTTP — so by - * then the binding is there. - */ - /* - * Both halves run whichever fails, and the failure is rethrown after. Their linked outside - * accounts keep resolving to them, now refused; the tokens go and every link needs reconnecting. + * A closure, because `pluginStore` is built further down: this has to exist before `auth` does, + * and that one needs the vault and the policy. `pluginStore` is read only when the closure runs — + * an administrator or the directory removing somebody, never during module initialisation — so by + * then the binding is there. The linked-account half writes its audit rows through a store made + * on its own transaction, so it reads no later binding (`bootAuditStore` included). */ (userId, by) => retireOwnedAccounts({ diff --git a/server/tests/identity-retire.integration.test.ts b/server/tests/identity-retire.integration.test.ts index c4cb1ac1f..fce05f306 100644 --- a/server/tests/identity-retire.integration.test.ts +++ b/server/tests/identity-retire.integration.test.ts @@ -435,6 +435,9 @@ test("both failing surfaces both", async () => { }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(AggregateError); expect((error as AggregateError).errors).toEqual([first, second]); + // Callers log String(error), which must name both causes. + expect(String(error)).toContain("plugins"); + expect(String(error)).toContain("identities"); }); test("both succeeding counts both", async () => { From 1af385aadec26ea8f9eb9bc604ef6453624cabf1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:37:50 -0300 Subject: [PATCH 055/101] docs: document the GitHub App sign-in variables --- .env.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.example b/.env.example index 478ddab20..c70ef2afb 100644 --- a/.env.example +++ b/.env.example @@ -436,6 +436,19 @@ WORKER_SHARED_SECRET= # with every call, so one account's connections are never reachable from another's. COMPOSIO_API_KEY= +# --------------------------------------------------------------------------- +# Linking people's GitHub accounts +# --------------------------------------------------------------------------- + +# A GitHub App lets people link their GitHub account under Settings -> Connected accounts -> Linked +# accounts. Register the App's callback URL as /api/identity/github/callback and keep +# "Expire user authorization tokens" on. A webhook is not needed for linking. Set both the client id +# and the client secret, or neither: one without the other stops the server at start-up. The slug is +# optional and is used for links to the App. +# GITHUB_APP_CLIENT_ID= +# GITHUB_APP_CLIENT_SECRET= +# GITHUB_APP_SLUG= + # --------------------------------------------------------------------------- # Reaching people outside the app: Slack, Teams, SMS, push # --------------------------------------------------------------------------- From 36c16e7f5e8c559fef20b9e1644050e02682303a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:38:05 -0300 Subject: [PATCH 056/101] feat(server): read GitHub App sign-in settings --- server/src/config.ts | 30 +++++++++++++ server/tests/config-github-app.test.ts | 59 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 server/tests/config-github-app.test.ts diff --git a/server/src/config.ts b/server/src/config.ts index bb557c663..d8196bea5 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -90,6 +90,12 @@ export type AuthProviderId = "google" | "microsoft" | "okta"; /** An OAuth client, as every provider here needs one. */ export type OAuthClient = { clientId: string; clientSecret: string }; +export type GithubAppConfig = { + clientId: string; + clientSecret: string; + slug?: string; +}; + export type AuthConfig = { baseUrl: string; secret: string; @@ -217,6 +223,8 @@ export type DeploymentConfig = { * where a key that was revoked last week would have surfaced regardless. */ composioApiKey: string | undefined; + /** GitHub App user sign-in, so people can link their GitHub account. Both or neither of GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRET. */ + githubApp?: GithubAppConfig; /** * Where this deployment is reached from outside, with no trailing slash. * @@ -664,6 +672,26 @@ function oauthClient( return clientId && clientSecret ? { clientId, clientSecret } : undefined; } +function githubAppConfig( + environment: Environment, +): GithubAppConfig | undefined { + const clientId = optional(environment, "GITHUB_APP_CLIENT_ID"); + const clientSecret = optional(environment, "GITHUB_APP_CLIENT_SECRET"); + const slug = optional(environment, "GITHUB_APP_SLUG"); + + // Both or neither. One alone is a half-configured sign-in that fails at the first attempt rather + // than at start-up, which is the worst moment to discover it. + if (Boolean(clientId) !== Boolean(clientSecret)) { + throw new Error( + "GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET must be set together", + ); + } + + return clientId && clientSecret + ? { clientId, clientSecret, ...(slug ? { slug } : {}) } + : undefined; +} + function commaSeparated(environment: Environment, name: string): string[] { return (optional(environment, name) ?? "") .split(",") @@ -1269,6 +1297,7 @@ export function loadConfig( ? organizationAuthority(organizationAuthValue) : undefined; const managedAgent = managedAgentConfig(environment); + const githubApp = githubAppConfig(environment); const workerSharedSecret = optional(environment, "WORKER_SHARED_SECRET"); return { @@ -1278,6 +1307,7 @@ export function loadConfig( databaseUrl: required(environment, "DATABASE_URL"), keyEncryptionKey: keyEncryptionKey(environment), ...(managedAgent ? { managedAgent } : {}), + ...(githubApp ? { githubApp } : {}), agentEndpointAllowedHosts: agentEndpointAllowedHosts(environment), deploymentId: optional(environment, "DEPLOYMENT_ID"), composioApiKey: optional(environment, "COMPOSIO_API_KEY"), diff --git a/server/tests/config-github-app.test.ts b/server/tests/config-github-app.test.ts new file mode 100644 index 000000000..5af173e0c --- /dev/null +++ b/server/tests/config-github-app.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { loadConfig } from "../src/config"; + +const baseEnvironment = { + DATABASE_URL: "postgres://openbot:openbot@localhost:5432/openbot", + KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + GOOGLE_OAUTH_CLIENT_ID: "google-client-id", + GOOGLE_OAUTH_CLIENT_SECRET: "google-client-secret", + BETTER_AUTH_SECRET: "a-long-enough-local-development-auth-secret", + BETTER_AUTH_URL: "http://localhost:3001", + INITIAL_ADMIN_EMAILS: "admin@openbot.test", + INTELLIGENCE_API_URL: "http://localhost:7100", + INTELLIGENCE_GATEWAY_WS_URL: "ws://localhost:7103", + INTELLIGENCE_API_KEY: "tenant-api-key", + COPILOTKIT_LICENSE_TOKEN: "license-token", + MANAGED_AGENT_AG_UI_URL: " http://localhost:4200/ag-ui ", + MANAGED_AGENT_TOKEN: "managed-agent-token", +}; + +describe("GitHub App sign-in configuration", () => { + test("is absent when no GITHUB_APP_* is set", () => { + expect(loadConfig(baseEnvironment).githubApp).toBeUndefined(); + }); + + test("reads the client id and secret", () => { + const config = loadConfig({ + ...baseEnvironment, + GITHUB_APP_CLIENT_ID: "gh-id", + GITHUB_APP_CLIENT_SECRET: "gh-secret", + }); + expect(config.githubApp).toEqual({ + clientId: "gh-id", + clientSecret: "gh-secret", + }); + }); + + test("includes the slug when set", () => { + const config = loadConfig({ + ...baseEnvironment, + GITHUB_APP_CLIENT_ID: "gh-id", + GITHUB_APP_CLIENT_SECRET: "gh-secret", + GITHUB_APP_SLUG: "openbot", + }); + expect(config.githubApp).toEqual({ + clientId: "gh-id", + clientSecret: "gh-secret", + slug: "openbot", + }); + }); + + test.each([ + { GITHUB_APP_CLIENT_ID: "gh-id", GITHUB_APP_CLIENT_SECRET: "" }, + { GITHUB_APP_CLIENT_ID: "", GITHUB_APP_CLIENT_SECRET: "gh-secret" }, + ])("refuses only one half of the pair", (half) => { + expect(() => loadConfig({ ...baseEnvironment, ...half })).toThrow( + "GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET must be set together", + ); + }); +}); From 1d1ea8d5fe9236663d92771913539d15fd669bb9 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:38:26 -0300 Subject: [PATCH 057/101] docs: explain linking Slack and GitHub accounts --- docs/configuration.md | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 37b73f791..7ea1aed06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -489,12 +489,12 @@ number opted out, later messages show `opted_out` in the delivery history instea ## Linked accounts OpenBot keeps a record of a person's accounts at outside providers, such as their GitHub account -or their Slack user. When a person has any, they appear under **Linked accounts** on -`/settings/connected-accounts`, and each can be disconnected there. Disconnecting revokes any token -stored with the link, and is audited. A person with none sees no such section. +or their Slack user. Whenever the deployment has an identity store, the section appears under +**Linked accounts** on `/settings/connected-accounts`. It lists the person's links and offers only +the actions this deployment can complete, and each link can be disconnected there. Disconnecting +revokes any token stored with the link, and is audited. -Links are created by the integrations that support them. Until one is configured, the list stays -empty. An integration proves an account in one of two ways: by the provider's own +See "Linking Slack and GitHub accounts" below for setting up each provider. An integration proves an account in one of two ways: by the provider's own sign-in, or by a one-time code that OpenBot issues to the signed-in person and that they send from their chat account. Whoever sends the code gets that chat account linked to the person it was issued to, so treat it like a password and do not share it. It is valid for ten minutes and works once, and @@ -508,9 +508,26 @@ marked as needing reconnection. The links are kept, not deleted, so the outside resolves to the removed person and is refused rather than treated as an unlinked guest. Each retired link is recorded in the audit trail. -This is separate from the OpenTag **Reachability** pairing above. That pairing binds a conversation -for delivery and uses its own `link ` messages, shown on the **Reachability** screen; it is not -recorded as a linked account. +### Linking Slack and GitHub accounts + +Slack requires the OpenTag pairing above, so `OPENTAG_SHARED_SECRET` must be set. The person clicks +**Link Slack**, which shows a message of the form `link `, and sends it to the OpenBot app in +Slack within ten minutes. Teams cannot be linked this way. + +GitHub uses a GitHub App. Create one under GitHub Settings, Developer settings, GitHub Apps, and set: + +- The callback URL to `/api/identity/github/callback`, where the public URL is + `OPENBOT_PUBLIC_URL` or, if that is unset, `BETTER_AUTH_URL`. +- "Expire user authorization tokens" on. A webhook is not required for linking. +- A client secret, generated on the App's page. + +Then set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`; set both or neither. `GITHUB_APP_SLUG` +is optional. People then use **Connect GitHub** while signed in to OpenBot in the same browser. The +GitHub user token is stored encrypted with the link. + +Reachability codes and Linked accounts codes look alike, and Reachability's are checked first. A +Reachability `link ` binds a conversation for delivery and is not recorded as a linked +account; a `link ` from **Linked accounts** links the Slack user. ## Text messages and push notifications From a222ab885c762d435e2787fce396cbe2de471290 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:38:34 -0300 Subject: [PATCH 058/101] feat(server): let the OpenTag handler redeem identity link codes --- server/src/delivery/opentag-agent.ts | 24 +++++++ server/src/delivery/routes.ts | 3 + server/tests/delivery-routes.test.ts | 96 +++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 1 deletion(-) diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index a3d1494f6..1e6109aac 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -92,6 +92,16 @@ export type OpenTagAgentDeps = { approvals?: PairedApprovals; /** Slack trigger ingest; defaults to the responsibilities lane's `ingestSlackEvent`. */ ingestSlack?: (event: SlackTriggerEvent) => Promise; + /** + * Redeems OpenBot linked-account codes (Settings → Connected accounts) for a Slack sender. + * Reachability codes are always checked first; this only sees codes that are not one. + */ + identity?: { + redeem( + code: string, + sender: { teamId: string; userId: string }, + ): Promise<"linked" | "conflict" | "invalid">; + }; }; const LINK = @@ -356,6 +366,20 @@ async function link( code: string, ) { const challenge = await deps.store.readChallenge(code); + if (!challenge && sender.transport === "slack" && deps.identity) { + const outcome = await deps.identity.redeem(code, { + teamId: sender.realm, + userId: sender.identity, + }); + if (outcome === "linked") + return { + text: "Linked your Slack account to OpenBot. You can see it under Settings → Connected accounts.", + }; + if (outcome === "conflict") + return { + text: "This Slack account is already linked to another OpenBot user.", + }; + } if (!challenge || challenge.transport !== sender.transport) return { text: "That link code expired, was already used, or is for another app. Create a new one in OpenBot.", diff --git a/server/src/delivery/routes.ts b/server/src/delivery/routes.ts index 13dc0fb64..3823ede4c 100644 --- a/server/src/delivery/routes.ts +++ b/server/src/delivery/routes.ts @@ -281,6 +281,8 @@ export function createDeliveryWebhookRoutes(deps: { approvals?: PairedApprovals; /** Test seam; production uses the responsibilities lane's `ingestSlackEvent`. */ ingestSlack?: OpenTagAgentDeps["ingestSlack"]; + /** Redeems OpenBot linked-account codes sent to the Slack app. */ + identity?: OpenTagAgentDeps["identity"]; }) { const routes = new Hono<{ Variables: AppVariables }>(); // A paired chat run carries its conversation's history, so it gets a larger envelope. @@ -303,6 +305,7 @@ export function createDeliveryWebhookRoutes(deps: { scopeFor: deps.scopeFor, ...(deps.approvals ? { approvals: deps.approvals } : {}), ...(deps.ingestSlack ? { ingestSlack: deps.ingestSlack } : {}), + ...(deps.identity ? { identity: deps.identity } : {}), }, c.req.raw, ), diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 677ac5650..156874001 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -20,7 +20,19 @@ const bound: DeliveryBinding = { address: "U1", enabled: true, }; -function fixture(options: { converse?: ConverseResult; bound?: boolean } = {}) { +type IdentityAdapter = { + redeem( + code: string, + sender: { teamId: string; userId: string }, + ): Promise<"linked" | "conflict" | "invalid">; +}; +function fixture( + options: { + converse?: ConverseResult; + bound?: boolean; + identity?: IdentityAdapter; + } = {}, +) { const calls: unknown[] = []; const decisions: unknown[] = []; const twilio = createTwilioTransport({ @@ -97,6 +109,7 @@ function fixture(options: { converse?: ConverseResult; bound?: boolean } = {}) { return { matched: 0, deliveries: [] }; }, twilio, + ...(options.identity ? { identity: options.identity } : {}), approvals: { store: { get: async (owner, id) => { @@ -223,6 +236,87 @@ test("a link code binds the verified sender to the challenge's own scope", async }, ]); }); +const IDENTITY_CODE = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const EXPIRED = + "That link code expired, was already used, or is for another app. Create a new one in OpenBot."; +function identityFixture(outcome?: "linked" | "conflict" | "invalid") { + const redeemed: unknown[] = []; + const f = fixture({ + bound: false, + ...(outcome + ? { + identity: { + redeem: async (code, who) => { + redeemed.push([code, who]); + return outcome; + }, + }, + } + : {}), + }); + return { f, redeemed }; +} +const linkMessage = (code: string) => ({ + messages: [ + { id: "m1", role: "user", content: `<@UBOT> link ${code.toUpperCase()}` }, + ], +}); +test("a Reachability code is checked before the identity adapter", async () => { + const { f, redeemed } = identityFixture("linked"); + const result = await run(f, { + context: [sender()], + ...linkMessage("11111111-2222-4333-8444-555555555555"), + }); + expect(said(result.events)).toContain("Linked"); + expect(f.calls).toHaveLength(1); + expect(redeemed).toEqual([]); +}); +test("an identity code from a Slack sender is redeemed through the adapter", async () => { + const { f, redeemed } = identityFixture("linked"); + const result = await run(f, { + context: [sender()], + ...linkMessage(IDENTITY_CODE), + }); + expect(said(result.events)).toBe( + "Linked your Slack account to OpenBot. You can see it under Settings → Connected accounts.", + ); + expect(redeemed).toEqual([[IDENTITY_CODE, { teamId: "T1", userId: "U1" }]]); +}); +test("an identity code conflict names the other user", async () => { + const { f } = identityFixture("conflict"); + const result = await run(f, { + context: [sender()], + ...linkMessage(IDENTITY_CODE), + }); + expect(said(result.events)).toBe( + "This Slack account is already linked to another OpenBot user.", + ); +}); +test("an invalid identity code gets the expired reply", async () => { + const { f } = identityFixture("invalid"); + const result = await run(f, { + context: [sender()], + ...linkMessage(IDENTITY_CODE), + }); + expect(said(result.events)).toBe(EXPIRED); +}); +test("a Teams sender never reaches the identity adapter", async () => { + const { f, redeemed } = identityFixture("linked"); + const result = await run(f, { + context: [sender("teams:tenant1:user1")], + ...linkMessage(IDENTITY_CODE), + }); + expect(said(result.events)).toBe(EXPIRED); + expect(redeemed).toEqual([]); +}); +test("without an identity adapter an unknown code gets the expired reply", async () => { + const { f } = identityFixture(); + const result = await run(f, { + context: [sender()], + ...linkMessage(IDENTITY_CODE), + }); + expect(said(result.events)).toBe(EXPIRED); +}); test("bound sender runs the canonical turn and an approval becomes an OpenTag card", async () => { const f = fixture({ converse: { From 911b726e5e245d6d1616af2fa2a5631d64904cae Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:38:36 -0300 Subject: [PATCH 059/101] feat(server): add GitHub App user sign-in client --- server/src/identity/github-oauth.ts | 209 ++++++++++++++++++++ server/tests/identity-github-oauth.test.ts | 213 +++++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 server/src/identity/github-oauth.ts create mode 100644 server/tests/identity-github-oauth.test.ts diff --git a/server/src/identity/github-oauth.ts b/server/src/identity/github-oauth.ts new file mode 100644 index 000000000..9057c00c0 --- /dev/null +++ b/server/src/identity/github-oauth.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; +import { seal, unseal } from "../auth/signed-value"; + +/** + * Signing a person in with the deployment's GitHub App. + * + * Four steps: build the authorize URL, remember who started it in a sealed state, trade the code + * for tokens, and ask GitHub who the token belongs to. Every failure is a `GithubOAuthError` with a + * fixed message: the vendor's body, the code, the tokens and the client secret never appear in one. + */ + +export const GITHUB_CALLBACK_PATH = "/api/identity/github/callback"; + +const STATE_LABEL = "identity-github-connect"; +const STATE_TTL_MS = 10 * 60_000; +const REQUEST_TIMEOUT_MS = 15_000; + +const AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; +const TOKEN_URL = "https://github.com/login/oauth/access_token"; +const USER_URL = "https://api.github.com/user"; + +export class GithubOAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "GithubOAuthError"; + } +} + +export function githubRedirectUri(publicUrl: string): string { + return `${publicUrl.replace(/\/+$/, "")}${GITHUB_CALLBACK_PATH}`; +} + +const stateSchema = z.object({ + userId: z.string().min(1), + issuedAt: z.iso.datetime(), +}); + +export async function sealGithubState( + userId: string, + encryptionKey: string, + now = new Date(), +): Promise { + return seal( + JSON.stringify({ userId, issuedAt: now.toISOString() }), + encryptionKey, + STATE_LABEL, + ); +} + +/** The person a state was issued to, or nothing for every way of being unusable. */ +export async function openGithubState( + sealed: string | undefined, + encryptionKey: string, + now = new Date(), +): Promise<{ userId: string } | null> { + const value = await unseal(sealed, encryptionKey, STATE_LABEL); + if (!value) return null; + + let json: unknown; + try { + json = JSON.parse(value); + } catch { + return null; + } + + const parsed = stateSchema.safeParse(json); + if (!parsed.success) return null; + + const age = now.getTime() - new Date(parsed.data.issuedAt).getTime(); + if (age < 0 || age > STATE_TTL_MS) return null; + + return { userId: parsed.data.userId }; +} + +/** No scope: a GitHub App's permissions come from its installation, not from the request. */ +export function githubAuthorizationUrl(input: { + clientId: string; + redirectUri: string; + state: string; +}): string { + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("state", input.state); + return url.toString(); +} + +export type GithubTokens = { + accessToken: string; + refreshToken: string | null; + expiresAt: string | null; + refreshTokenExpiresAt: string | null; +}; + +const tokenResponseSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1).optional(), + expires_in: z.number().positive().optional(), + refresh_token_expires_in: z.number().positive().optional(), +}); + +function secondsFrom(now: Date, seconds: number | undefined): string | null { + return seconds === undefined + ? null + : new Date(now.getTime() + seconds * 1000).toISOString(); +} + +export async function exchangeGithubCode( + input: { + clientId: string; + clientSecret: string; + code: string; + redirectUri: string; + }, + fetchImpl: typeof fetch = fetch, + now = new Date(), +): Promise { + let response: Response; + try { + response = await fetchImpl(TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: input.clientId, + client_secret: input.clientSecret, + code: input.code, + redirect_uri: input.redirectUri, + }), + // The request carries a client secret and a code; a redirect is a refusal, not a detour. + redirect: "manual", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new GithubOAuthError("GitHub could not be reached"); + } + + if (!response.ok) { + throw new GithubOAuthError("GitHub refused the authorization code"); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new GithubOAuthError("GitHub returned an unreadable response"); + } + + // GitHub reports a bad code as a 200 with an `error` field, so no access_token is the refusal. + const parsed = tokenResponseSchema.safeParse(body); + if (!parsed.success) { + throw new GithubOAuthError("GitHub did not return an access token"); + } + + return { + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token ?? null, + expiresAt: secondsFrom(now, parsed.data.expires_in), + refreshTokenExpiresAt: secondsFrom( + now, + parsed.data.refresh_token_expires_in, + ), + }; +} + +const userSchema = z.object({ + id: z.number().int().positive(), + login: z.string().min(1).max(39), +}); + +export async function fetchGithubUser( + accessToken: string, + fetchImpl: typeof fetch = fetch, +): Promise<{ id: number; login: string }> { + let response: Response; + try { + response = await fetchImpl(USER_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "manual", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new GithubOAuthError("GitHub could not be reached"); + } + + if (!response.ok) { + throw new GithubOAuthError("GitHub refused to identify the account"); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new GithubOAuthError("GitHub returned an unreadable response"); + } + + const parsed = userSchema.safeParse(body); + if (!parsed.success) { + throw new GithubOAuthError("GitHub returned an unexpected account"); + } + return { id: parsed.data.id, login: parsed.data.login }; +} diff --git a/server/tests/identity-github-oauth.test.ts b/server/tests/identity-github-oauth.test.ts new file mode 100644 index 000000000..b38a0d43f --- /dev/null +++ b/server/tests/identity-github-oauth.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "bun:test"; +import { seal } from "../src/auth/signed-value"; +import { + exchangeGithubCode, + fetchGithubUser, + GithubOAuthError, + githubAuthorizationUrl, + githubRedirectUri, + openGithubState, + sealGithubState, +} from "../src/identity/github-oauth"; + +const key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const now = new Date("2026-01-01T00:00:00.000Z"); + +type Call = { url: string; init: RequestInit }; + +function stub(response: () => Response) { + const calls: Call[] = []; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + calls.push({ url: String(url), init: init ?? {} }); + return response(); + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +const input = { + clientId: "client-id", + clientSecret: "super-secret-value", + code: "one-time-code-123", + redirectUri: "https://o.test/api/identity/github/callback", +}; + +describe("GitHub authorize URL and redirect URI", () => { + test("authorize URL carries exactly client_id, redirect_uri and state", () => { + const url = new URL( + githubAuthorizationUrl({ + clientId: "cid", + redirectUri: "https://o.test/cb", + state: "st", + }), + ); + expect(`${url.origin}${url.pathname}`).toBe( + "https://github.com/login/oauth/authorize", + ); + expect([...url.searchParams.keys()].sort()).toEqual([ + "client_id", + "redirect_uri", + "state", + ]); + expect(url.searchParams.get("redirect_uri")).toBe("https://o.test/cb"); + }); + + test("redirect URI strips trailing slashes", () => { + expect(githubRedirectUri("https://o.test")).toBe( + "https://o.test/api/identity/github/callback", + ); + expect(githubRedirectUri("https://o.test//")).toBe( + "https://o.test/api/identity/github/callback", + ); + }); +}); + +describe("GitHub state", () => { + test("round-trips to the user", async () => { + const sealed = await sealGithubState("u1", key, now); + expect(await openGithubState(sealed, key, now)).toEqual({ userId: "u1" }); + }); + + test("expires after ten minutes", async () => { + const sealed = await sealGithubState("u1", key, now); + const later = new Date(now.getTime() + 11 * 60_000); + expect(await openGithubState(sealed, key, later)).toBeNull(); + }); + + test("is rejected from the future", async () => { + const sealed = await sealGithubState("u1", key, now); + const earlier = new Date(now.getTime() - 60_000); + expect(await openGithubState(sealed, key, earlier)).toBeNull(); + }); + + test("missing and garbage values are null", async () => { + expect(await openGithubState(undefined, key, now)).toBeNull(); + expect(await openGithubState("garbage", key, now)).toBeNull(); + }); + + test("another label is null", async () => { + const sealed = await seal( + JSON.stringify({ userId: "u", issuedAt: now.toISOString() }), + key, + "other-label", + ); + expect(await openGithubState(sealed, key, now)).toBeNull(); + }); + + test("wrong shape is null", async () => { + const sealed = await seal( + JSON.stringify({ userId: "", issuedAt: now.toISOString() }), + key, + "identity-github-connect", + ); + expect(await openGithubState(sealed, key, now)).toBeNull(); + }); +}); + +describe("exchangeGithubCode", () => { + test("posts JSON and maps expiries from now", async () => { + const { calls, fetchImpl } = stub(() => + Response.json({ + access_token: "at", + refresh_token: "rt", + expires_in: 28800, + refresh_token_expires_in: 15811200, + }), + ); + const tokens = await exchangeGithubCode(input, fetchImpl, now); + expect(tokens).toEqual({ + accessToken: "at", + refreshToken: "rt", + expiresAt: new Date(now.getTime() + 28800_000).toISOString(), + refreshTokenExpiresAt: new Date( + now.getTime() + 15811200_000, + ).toISOString(), + }); + const call = calls[0]; + expect(call.url).toBe("https://github.com/login/oauth/access_token"); + expect(call.init.method).toBe("POST"); + expect(call.init.redirect).toBe("manual"); + const headers = new Headers(call.init.headers); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.get("content-type")).toBe("application/json"); + expect(JSON.parse(String(call.init.body))).toEqual({ + client_id: "client-id", + client_secret: "super-secret-value", + code: "one-time-code-123", + redirect_uri: input.redirectUri, + }); + }); + + test("absent expiries and refresh token become null", async () => { + const { fetchImpl } = stub(() => Response.json({ access_token: "at" })); + expect(await exchangeGithubCode(input, fetchImpl, now)).toEqual({ + accessToken: "at", + refreshToken: null, + expiresAt: null, + refreshTokenExpiresAt: null, + }); + }); + + test("a 200 error body throws without echoing secrets", async () => { + const { fetchImpl } = stub(() => + Response.json({ error: "bad_verification_code" }), + ); + const error = await exchangeGithubCode(input, fetchImpl, now).catch( + (e) => e, + ); + expect(error).toBeInstanceOf(GithubOAuthError); + expect(error.message).not.toContain(input.code); + expect(error.message).not.toContain(input.clientSecret); + expect(error.message).not.toContain("bad_verification_code"); + }); + + test("non-ok status throws", async () => { + const { fetchImpl } = stub(() => new Response("nope", { status: 500 })); + await expect(exchangeGithubCode(input, fetchImpl, now)).rejects.toThrow( + GithubOAuthError, + ); + }); + + test("transport failure throws GithubOAuthError", async () => { + const fetchImpl = (async () => { + throw new Error(`connect failed ${input.clientSecret}`); + }) as unknown as typeof fetch; + const error = await exchangeGithubCode(input, fetchImpl, now).catch( + (e) => e, + ); + expect(error).toBeInstanceOf(GithubOAuthError); + expect(error.message).not.toContain(input.clientSecret); + }); +}); + +describe("fetchGithubUser", () => { + test("sends bearer headers and returns id and login", async () => { + const { calls, fetchImpl } = stub(() => + Response.json({ id: 42, login: "octocat", email: "x@y.z" }), + ); + expect(await fetchGithubUser("tok", fetchImpl)).toEqual({ + id: 42, + login: "octocat", + }); + const headers = new Headers(calls[0].init.headers); + expect(calls[0].url).toBe("https://api.github.com/user"); + expect(headers.get("authorization")).toBe("Bearer tok"); + expect(headers.get("accept")).toBe("application/vnd.github+json"); + expect(headers.get("x-github-api-version")).toBe("2022-11-28"); + expect(calls[0].init.redirect).toBe("manual"); + }); + + test.each([ + ["non-integer id", () => Response.json({ id: 1.5, login: "a" })], + ["string id", () => Response.json({ id: "1", login: "a" })], + ["empty login", () => Response.json({ id: 1, login: "" })], + ["non-ok", () => new Response("x", { status: 401 })], + ])("%s throws", async (_name, response) => { + const { fetchImpl } = stub(response); + await expect(fetchGithubUser("tok", fetchImpl)).rejects.toThrow( + GithubOAuthError, + ); + }); +}); From e8a18d03851a405ab74c3f52b4f25bed5604d189 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:40:06 -0300 Subject: [PATCH 060/101] feat(app): ask which account types a person can link --- app/src/lib/identity/queries.test.ts | 42 ++++++++++++++++++++++++++ app/src/lib/identity/queries.ts | 44 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 app/src/lib/identity/queries.test.ts diff --git a/app/src/lib/identity/queries.test.ts b/app/src/lib/identity/queries.test.ts new file mode 100644 index 000000000..8b5bf534e --- /dev/null +++ b/app/src/lib/identity/queries.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { providersFromResponse } from "./queries"; + +describe("providersFromResponse", () => { + test("reads the provider flags", async () => { + const response = Response.json({ + providers: { slack: true, github: false }, + }); + expect(await providersFromResponse(response)).toEqual({ + slack: true, + github: false, + }); + }); + + test("treats the tagged 503 as nothing linkable", async () => { + const response = Response.json( + { error: "No identity store", code: "identity_unavailable" }, + { status: 503 }, + ); + expect(await providersFromResponse(response)).toEqual({ + slack: false, + github: false, + }); + }); + + test("throws the server's message for any other 503", async () => { + const response = Response.json({ error: "Bad gateway" }, { status: 503 }); + await expect(providersFromResponse(response)).rejects.toThrow( + "Bad gateway", + ); + }); + + test("throws when providers is missing or not boolean-shaped", async () => { + const message = "Could not load the account types you can link"; + await expect(providersFromResponse(Response.json({}))).rejects.toThrow( + message, + ); + await expect( + providersFromResponse(Response.json({ providers: { slack: "yes" } })), + ).rejects.toThrow(message); + }); +}); diff --git a/app/src/lib/identity/queries.ts b/app/src/lib/identity/queries.ts index 91dcb8a27..3d20e3b99 100644 --- a/app/src/lib/identity/queries.ts +++ b/app/src/lib/identity/queries.ts @@ -13,6 +13,7 @@ export type LinkedAccount = { export const identityKeys = { all: ["identity"] as const, links: () => [...identityKeys.all, "links"] as const, + providers: () => [...identityKeys.all, "providers"] as const, }; const LOAD_FALLBACK = "Could not load your linked accounts"; @@ -54,3 +55,46 @@ export function linkedAccountsQueryOptions() { linksFromResponse(await tryClient("/api/identity/links")), }); } + +/** Which account types this deployment can link a person to. */ +export type IdentityProviders = { slack: boolean; github: boolean }; + +const PROVIDERS_FALLBACK = "Could not load the account types you can link"; + +/** The tagged 503 means no identity store, so nothing is linkable; other failures throw. */ +export async function providersFromResponse( + response: Response, +): Promise { + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: unknown; + code?: unknown; + } | null; + if (response.status === 503 && body?.code === IDENTITY_UNAVAILABLE) { + return { slack: false, github: false }; + } + throw new Error( + typeof body?.error === "string" ? body.error : PROVIDERS_FALLBACK, + ); + } + const body = (await response.json().catch(() => null)) as { + providers?: { slack?: unknown; github?: unknown }; + } | null; + const providers = body?.providers; + if ( + !providers || + typeof providers.slack !== "boolean" || + typeof providers.github !== "boolean" + ) { + throw new Error(PROVIDERS_FALLBACK); + } + return { slack: providers.slack, github: providers.github }; +} + +export function identityProvidersQueryOptions() { + return queryOptions({ + queryKey: identityKeys.providers(), + queryFn: async (): Promise => + providersFromResponse(await tryClient("/api/identity/providers")), + }); +} From db8b61b9dbcf7db0b1b4cfd9d93ee4190cea2de9 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:40:09 -0300 Subject: [PATCH 061/101] feat(app): add Slack link code and GitHub connect requests --- app/src/lib/identity/mutations.ts | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/lib/identity/mutations.ts b/app/src/lib/identity/mutations.ts index 28c212c2a..a0fdeea36 100644 --- a/app/src/lib/identity/mutations.ts +++ b/app/src/lib/identity/mutations.ts @@ -1,5 +1,5 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query"; -import { tryClient } from "@/lib/client"; +import { client, tryClient } from "@/lib/client"; import { identityKeys } from "./queries"; const UNLINK_FALLBACK = "The account could not be disconnected"; @@ -39,3 +39,41 @@ export function unlinkMutationOptions(queryClient: QueryClient) { queryClient.invalidateQueries({ queryKey: identityKeys.links() }), }); } + +/** A one-time code the person sends to the bot in Slack to link their account. */ +export type SlackLinkCode = { + code: string; + expiresAt: string; + instruction: string; +}; + +export function issueSlackCodeMutationOptions() { + return mutationOptions({ + mutationFn: (): Promise => + client("/api/identity/challenges", "challenge", { + method: "POST", + body: { provider: "slack" }, + fallback: "Could not create a Slack link code", + }), + }); +} + +/** Resolves to the GitHub consent URL; the caller navigates, not this factory. */ +export function connectGithubMutationOptions() { + return mutationOptions({ + mutationFn: async (): Promise => { + const { authorizationUrl } = await client<{ authorizationUrl: unknown }>( + "/api/identity/github/connect", + "connect", + { + method: "POST", + fallback: "Could not start connecting GitHub", + }, + ); + if (typeof authorizationUrl !== "string") { + throw new Error("Could not start connecting GitHub"); + } + return authorizationUrl; + }, + }); +} From e82a92818646927fd3d65a0dd6e09ff6db763438 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:43:49 -0300 Subject: [PATCH 062/101] feat(server): report which account types can be linked --- server/src/identity/routes.ts | 15 +++++++++++ server/tests/identity-routes.test.ts | 37 ++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index 8f6734e48..a4dddde3b 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -4,6 +4,11 @@ import type { AppVariables } from "../auth/guards"; import { PROVIDERS } from "./providers"; import type { IdentityStore } from "./store"; +export type IdentityRouteOptions = { + slackLinking?: boolean; + github?: { clientId: string; publicUrl: string; encryptionKey: string }; +}; + /** * A person's own linked accounts. * @@ -15,6 +20,7 @@ export function identityRoutes( requireUser: MiddlewareHandler<{ Variables: AppVariables }>, store?: Pick, auditStore?: AuditStore, + options: IdentityRouteOptions = {}, ) { const app = new Hono<{ Variables: AppVariables }>(); app.use("*", async (context, next) => { @@ -40,6 +46,15 @@ export function identityRoutes( } const live = store; + app.get("/providers", (context) => + context.json({ + providers: { + slack: Boolean(options.slackLinking), + github: Boolean(options.github), + }, + }), + ); + app.get("/links", async (context) => { const links = await live.identitiesFor(context.var.actor.id); return context.json({ diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index f2fd0f945..2c7fea7bd 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -2,7 +2,10 @@ import { expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import type { AuditEventInput } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; -import { identityRoutes } from "../src/identity/routes"; +import { + type IdentityRouteOptions, + identityRoutes, +} from "../src/identity/routes"; import type { IdentityLink } from "../src/identity/types"; const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async ( @@ -30,7 +33,7 @@ const link: IdentityLink = { updatedAt: new Date("2026-10-08T00:00:00Z"), }; -function fixture(removedProvider = "github") { +function fixture(removedProvider = "github", options?: IdentityRouteOptions) { const calls: string[] = []; const events: AuditEventInput[] = []; const app = identityRoutes( @@ -45,6 +48,7 @@ function fixture(removedProvider = "github") { }, }, { insert: async (event) => void events.push(event) }, + options, ); return { app, calls, events }; } @@ -156,3 +160,32 @@ test("the audit names the provider the store removed, even one outside the regis provider: "retired-provider", }); }); + +const github = { + clientId: "Iv1.x", + publicUrl: "https://o.test", + encryptionKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", +}; + +test("GET /providers reports which account types can be linked", async () => { + const cases: [IdentityRouteOptions, { slack: boolean; github: boolean }][] = [ + [{}, { slack: false, github: false }], + [{ slackLinking: true }, { slack: true, github: false }], + [{ github }, { slack: false, github: true }], + ]; + for (const [options, providers] of cases) { + const response = await fixture("github", options).app.request("/providers"); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ providers }); + } +}); + +test("GET /providers without a store is 503", async () => { + const response = await identityRoutes(signedIn).request("/providers"); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: "Linked accounts are not available.", + code: "identity_unavailable", + }); +}); From 5db674f1334518590c4da7d3849b5da984189079 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:44:06 -0300 Subject: [PATCH 063/101] feat(server): redeem Slack link codes into identity links --- server/src/audit.ts | 5 + server/src/identity/slack-redeem.ts | 59 ++++++++++ server/tests/identity-slack-redeem.test.ts | 119 +++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 server/src/identity/slack-redeem.ts create mode 100644 server/tests/identity-slack-redeem.test.ts diff --git a/server/src/audit.ts b/server/src/audit.ts index 36c4ecb33..d53ad7722 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -511,6 +511,11 @@ export const auditEventTypes = [ /* A Bot connected to, or disconnected from, a Slack, Teams or SMS conversation. */ "delivery.linked", "delivery.unlinked", + /* + * A person linking an outside account to themselves (a Slack link code, a GitHub sign-in). Target + * the link id; payload `actor` and `provider` only, never the subject, realm or credential id. + */ + "identity.linked", /* * A person removing their own link to an outside account (Slack, GitHub). The link and its stored * token go here; nothing is changed at the provider. diff --git a/server/src/identity/slack-redeem.ts b/server/src/identity/slack-redeem.ts new file mode 100644 index 000000000..37b156235 --- /dev/null +++ b/server/src/identity/slack-redeem.ts @@ -0,0 +1,59 @@ +import { type AuditStore, recordAuditEvent } from "../audit"; +import { slackRealm } from "./providers"; +import type { IdentityStore } from "./store"; +import { + IdentityConflictError, + IdentityInputError, + IdentityLinkError, +} from "./types"; + +/** + * Redeems a link code a person sent from Slack. Failures a person can cause become a result; any + * other error propagates. The audit event is written after the link commits and is not caught, as + * for unlinking: it names the link and provider, never the realm or Slack subject. + */ +export function slackCodeRedeemer( + store: Pick, + auditStore?: AuditStore, +): { + redeem( + code: string, + sender: { teamId: string; userId: string }, + ): Promise<"linked" | "conflict" | "invalid">; +} { + return { + async redeem(code, sender) { + let link: Awaited>; + try { + const realm = slackRealm({ + connectionId: "opentag", + installationId: "opentag", + workspaceId: sender.teamId, + }); + link = await store.redeemChallenge(code, { + provider: "slack", + realm, + subject: sender.userId, + }); + } catch (error) { + if ( + error instanceof IdentityLinkError || + error instanceof IdentityInputError + ) + return "invalid"; + if (error instanceof IdentityConflictError) return "conflict"; + throw error; + } + if (auditStore) { + await recordAuditEvent(auditStore, { + eventType: "identity.linked", + targetType: "identity_link", + targetId: link.id, + actorUserId: link.userId, + payload: { actor: link.userId, provider: "slack" }, + }); + } + return "linked"; + }, + }; +} diff --git a/server/tests/identity-slack-redeem.test.ts b/server/tests/identity-slack-redeem.test.ts new file mode 100644 index 000000000..b5e790ee5 --- /dev/null +++ b/server/tests/identity-slack-redeem.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import type { AuditEventInput, AuditStore } from "../src/audit"; +import { slackCodeRedeemer } from "../src/identity/slack-redeem"; +import type { IdentityStore } from "../src/identity/store"; +import { + IdentityConflictError, + IdentityInputError, + type IdentityLink, + IdentityLinkError, +} from "../src/identity/types"; + +const sender = { teamId: "T1", userId: "U1" }; + +function setup(behavior: () => Promise) { + const calls: unknown[][] = []; + const events: AuditEventInput[] = []; + const store = { + redeemChallenge: async (...args: unknown[]) => { + calls.push(args); + return behavior(); + }, + } as unknown as Pick; + const auditStore: AuditStore = { + insert: async (event) => { + events.push(event); + }, + }; + return { calls, events, store, auditStore }; +} + +const link = { + id: "link-1", + userId: "user-1", + provider: "slack", + realm: "opentag:opentag:T1", + subject: "U1", +} as unknown as IdentityLink; + +describe("slackCodeRedeemer", () => { + test("links, audits without realm or subject", async () => { + const { calls, events, store, auditStore } = setup(async () => link); + const result = await slackCodeRedeemer(store, auditStore).redeem( + "CODE", + sender, + ); + expect(result).toBe("linked"); + expect(calls).toHaveLength(1); + expect(calls[0]).toHaveLength(2); + expect(calls[0]).toEqual([ + "CODE", + { provider: "slack", realm: "opentag:opentag:T1", subject: "U1" }, + ]); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + eventType: "identity.linked", + targetType: "identity_link", + targetId: "link-1", + actorUserId: "user-1", + payload: { actor: "user-1", provider: "slack" }, + }); + const json = JSON.stringify(events[0]); + expect(json).not.toContain("opentag:opentag"); + expect(json).not.toContain("U1"); + }); + + test("an invalid code is invalid and not audited", async () => { + const { events, store, auditStore } = setup(async () => { + throw new IdentityLinkError(); + }); + expect(await slackCodeRedeemer(store, auditStore).redeem("X", sender)).toBe( + "invalid", + ); + expect(events).toHaveLength(0); + }); + + test("an input error is invalid", async () => { + const { store, auditStore } = setup(async () => { + throw new IdentityInputError(); + }); + expect(await slackCodeRedeemer(store, auditStore).redeem("X", sender)).toBe( + "invalid", + ); + }); + + test("an incomplete sender is invalid", async () => { + const { calls, store, auditStore } = setup(async () => link); + expect( + await slackCodeRedeemer(store, auditStore).redeem("X", { + teamId: " ", + userId: "U1", + }), + ).toBe("invalid"); + expect(calls).toHaveLength(0); + }); + + test("a conflict is reported and not audited", async () => { + const { events, store, auditStore } = setup(async () => { + throw new IdentityConflictError(); + }); + expect(await slackCodeRedeemer(store, auditStore).redeem("X", sender)).toBe( + "conflict", + ); + expect(events).toHaveLength(0); + }); + + test("other errors rethrow", async () => { + const { store, auditStore } = setup(async () => { + throw new Error("db down"); + }); + await expect( + slackCodeRedeemer(store, auditStore).redeem("X", sender), + ).rejects.toThrow("db down"); + }); + + test("works without an audit store", async () => { + const { store } = setup(async () => link); + expect(await slackCodeRedeemer(store).redeem("X", sender)).toBe("linked"); + }); +}); From e0217089c262f50c2290e0f115dd66d793de639b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:44:07 -0300 Subject: [PATCH 064/101] feat(app): show linked accounts whenever linking is available --- .../settings/linked-accounts.test.tsx | 26 +++++++++++++++++ .../components/settings/linked-accounts.tsx | 28 ++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index f8b5c1ece..429cbfb53 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -120,6 +120,32 @@ test("the section hides while loading and when nothing is linked", () => { expect(linkedAccountsSectionState({ data: [], error: null })).toBe(null); }); +test("the section shows an empty list whenever linking is available", () => { + for (const providers of [ + { slack: true, github: false }, + { slack: false, github: true }, + ]) { + for (const data of [undefined, []]) { + expect( + linkedAccountsSectionState({ data, error: null, providers }), + ).toEqual({ rows: [], error: null }); + } + } +}); + +test("the section stays hidden when no provider is available", () => { + expect( + linkedAccountsSectionState({ + data: [], + error: null, + providers: { slack: false, github: false }, + }), + ).toBe(null); + expect( + linkedAccountsSectionState({ data: [], error: null, providers: undefined }), + ).toBe(null); +}); + test("rows show on their own when the list loaded", () => { expect(linkedAccountsSectionState({ data: [row], error: null })).toEqual({ rows: [row], diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index df68a1947..5707359a1 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -1,6 +1,10 @@ import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { PageRows, PageSection } from "@/components/layout/page-shell"; +import { + PageEmpty, + PageRows, + PageSection, +} from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; import { Item, @@ -13,6 +17,8 @@ import { import { Separator } from "@/components/ui/separator"; import { unlinkMutationOptions } from "@/lib/identity/mutations"; import { + type IdentityProviders, + identityProvidersQueryOptions, type LinkedAccount, linkedAccountsQueryOptions, } from "@/lib/identity/queries"; @@ -43,7 +49,8 @@ export function providerIcon(provider: string) { /** * What the section draws: nothing, rows, an error, or rows with an error under them. * - * Hidden while loading and when nothing is linked (or the deployment has no identity store). A + * Hidden while loading and when nothing is linked and nothing can be (no identity store, or no + * provider available to link); shown empty when a provider is available. A * failed refetch keeps the rows already on screen — stale but visible — and says the list could not * be refreshed, so a removed account that is still drawn is never shown without that warning. An * error with no rows to keep, including over a cached empty list, shows on its own. @@ -51,13 +58,18 @@ export function providerIcon(provider: string) { export function linkedAccountsSectionState({ data, error, + providers, }: { data: LinkedAccount[] | undefined; error: Error | null; + providers?: IdentityProviders; }): { rows: LinkedAccount[]; error: string | null } | null { const rows = data ?? []; if (error) return { rows, error: error.message }; - return rows.length > 0 ? { rows, error: null } : null; + if (rows.length > 0 || providers?.slack || providers?.github) { + return { rows, error: null }; + } + return null; } /** @@ -68,13 +80,21 @@ export function linkedAccountsSectionState({ */ export function LinkedAccountsSection() { const links = useQuery(linkedAccountsQueryOptions()); + const providers = useQuery(identityProvidersQueryOptions()); const state = linkedAccountsSectionState({ data: links.data, error: links.error, + providers: providers.data, }); if (!state) return null; return ( - + {/* actions */}} + > + {state.rows.length === 0 && !state.error ? ( + No linked accounts yet. + ) : null} {state.rows.length > 0 ? ( {state.rows.map((account, index) => ( From 791a4c28c9e72d4513e0d9844e2f4532c130c444 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:44:07 -0300 Subject: [PATCH 065/101] feat(app): report the linked-account result on connected accounts --- .../components/settings/linked-notice.test.ts | 25 +++++++++++++++ app/src/components/settings/linked-notice.ts | 18 +++++++++++ .../settings/connected-accounts/index.tsx | 32 +++++++++++++++++-- 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 app/src/components/settings/linked-notice.test.ts create mode 100644 app/src/components/settings/linked-notice.ts diff --git a/app/src/components/settings/linked-notice.test.ts b/app/src/components/settings/linked-notice.test.ts new file mode 100644 index 000000000..7bde1471d --- /dev/null +++ b/app/src/components/settings/linked-notice.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { linkedNotice } from "./linked-notice"; + +describe("linkedNotice", () => { + test("reports a linked GitHub account", () => { + expect(linkedNotice("github")).toEqual({ + tone: "success", + text: "Your GitHub account is linked.", + }); + }); + + test("reports a failed link", () => { + expect(linkedNotice("failed")).toEqual({ + tone: "error", + text: "That account could not be linked. Nothing was saved — try again.", + }); + }); + + test.each([undefined, "", "slack", "__proto__"])( + "says nothing for %p", + (value) => { + expect(linkedNotice(value)).toBeNull(); + }, + ); +}); diff --git a/app/src/components/settings/linked-notice.ts b/app/src/components/settings/linked-notice.ts new file mode 100644 index 000000000..02472c581 --- /dev/null +++ b/app/src/components/settings/linked-notice.ts @@ -0,0 +1,18 @@ +export type LinkedNotice = { tone: "success" | "error"; text: string }; + +/** + * What the connected accounts page says about the `?linked=` the GitHub callback redirected back + * with. Only the two values the callback sends have a sentence; anything else says nothing. + */ +export function linkedNotice(value: string | undefined): LinkedNotice | null { + if (value === "github") { + return { tone: "success", text: "Your GitHub account is linked." }; + } + if (value === "failed") { + return { + tone: "error", + text: "That account could not be linked. Nothing was saved — try again.", + }; + } + return null; +} diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index f18a1327a..9466d59fb 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -16,6 +16,7 @@ import { import { RowMark } from "@/components/layout/row-mark"; import { PluginLogo } from "@/components/plugins/plugin-logo"; import { LinkedAccountsSection } from "@/components/settings/linked-accounts"; +import { linkedNotice } from "@/components/settings/linked-notice"; import { InputGroup, InputGroupAddon, @@ -52,8 +53,14 @@ 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; linked?: string } => ({ + ...(typeof search.connected === "string" + ? { connected: search.connected } + : {}), + ...(typeof search.linked === "string" ? { linked: search.linked } : {}), + }), }); /** The same marks the admin connector list uses: these are the same vendors seen from your side. */ @@ -81,7 +88,15 @@ export function brokeredAccountsListedOn( } function RouteComponent() { - const { connected: outcome } = Route.useSearch(); + const { connected: outcome, linked } = Route.useSearch(); + /* Read once, then dropped from the URL, so a refresh does not repeat the news. */ + const [notice] = React.useState(() => linkedNotice(linked)); + const navigate = Route.useNavigate(); + React.useEffect(() => { + if (linked !== undefined) { + navigate({ search: ({ linked: _l, ...rest }) => rest, replace: true }); + } + }, [linked, navigate]); const [search, setSearch] = React.useState(""); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); @@ -174,6 +189,17 @@ function RouteComponent() { That account could not be connected. Nothing was saved — try again.

) : null} + {notice ? ( + notice.tone === "error" ? ( +

+ {notice.text} +

+ ) : ( +

+ {notice.text} +

+ ) + ) : 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 From 47df1ede5e9510987f82c71426e4d2646f563ddb Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:46:15 -0300 Subject: [PATCH 066/101] feat(server): offer Slack link codes from identity routes --- server/src/identity/routes.ts | 36 +++++++++++++- server/tests/identity-routes.test.ts | 72 +++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index a4dddde3b..f2059168b 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -1,4 +1,5 @@ import { Hono, type MiddlewareHandler } from "hono"; +import { z } from "zod"; import { type AuditStore, recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; import { PROVIDERS } from "./providers"; @@ -18,7 +19,7 @@ export type IdentityRouteOptions = { */ export function identityRoutes( requireUser: MiddlewareHandler<{ Variables: AppVariables }>, - store?: Pick, + store?: Pick, auditStore?: AuditStore, options: IdentityRouteOptions = {}, ) { @@ -55,6 +56,39 @@ export function identityRoutes( }), ); + app.post("/challenges", async (context) => { + if (!options.slackLinking) + return context.json( + { + error: "That account type cannot be linked here.", + code: "identity_provider_unavailable", + }, + 404, + ); + const parsed = z + .strictObject({ provider: z.literal("slack") }) + .safeParse(await context.req.json().catch(() => null)); + if (!parsed.success) + return context.json( + { + error: "Choose an account type to link.", + code: "identity_invalid_request", + }, + 400, + ); + const { code, expiresAt } = await live.issueChallenge( + context.var.actor.id, + "slack", + ); + return context.json({ + challenge: { + code, + expiresAt: expiresAt.toISOString(), + instruction: PROVIDERS.slack.instruction?.(code) ?? "", + }, + }); + }); + app.get("/links", async (context) => { const links = await live.identitiesFor(context.var.actor.id); return context.json({ diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 2c7fea7bd..4c8043e91 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -36,6 +36,7 @@ const link: IdentityLink = { function fixture(removedProvider = "github", options?: IdentityRouteOptions) { const calls: string[] = []; const events: AuditEventInput[] = []; + const issued: [string, string][] = []; const app = identityRoutes( signedIn, { @@ -46,11 +47,18 @@ function fixture(removedProvider = "github", options?: IdentityRouteOptions) { ? { provider: removedProvider } : null; }, + issueChallenge: async (userId, provider) => { + issued.push([userId, provider]); + return { + code: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + expiresAt: new Date("2026-10-08T00:10:00Z"), + }; + }, }, { insert: async (event) => void events.push(event) }, options, ); - return { app, calls, events }; + return { app, calls, events, issued }; } test("links lists the asker's links without subject, realm or credential", async () => { @@ -189,3 +197,65 @@ test("GET /providers without a store is 503", async () => { code: "identity_unavailable", }); }); + +function postChallenge(app: ReturnType["app"], body: string) { + return app.request("/challenges", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); +} + +test("POST /challenges issues a Slack link code", async () => { + const f = fixture("github", { slackLinking: true }); + const response = await postChallenge(f.app, '{"provider":"slack"}'); + expect(response.status).toBe(200); + const body = (await response.json()) as { + challenge: { code: string; expiresAt: string; instruction: string }; + }; + expect(body.challenge.code).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); + expect(body.challenge.expiresAt).toBe("2026-10-08T00:10:00.000Z"); + expect(body.challenge.instruction).toContain( + "link aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + ); + expect(f.issued).toEqual([["person", "slack"]]); +}); + +test("POST /challenges is 404 unless Slack linking is on", async () => { + for (const options of [undefined, {}, { slackLinking: false }]) { + const f = fixture("github", options); + const response = await postChallenge(f.app, '{"provider":"slack"}'); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: expect.any(String), + code: "identity_provider_unavailable", + }); + expect(f.issued).toEqual([]); + } +}); + +test("POST /challenges rejects any other body", async () => { + for (const raw of [ + '{"provider":"github"}', + '{"provider":"slack","x":1}', + "not json", + ]) { + const f = fixture("github", { slackLinking: true }); + const response = await postChallenge(f.app, raw); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Choose an account type to link.", + code: "identity_invalid_request", + }); + expect(f.issued).toEqual([]); + } +}); + +test("POST /challenges without a store is 503", async () => { + const response = await postChallenge( + identityRoutes(signedIn, undefined, undefined, { slackLinking: true }), + '{"provider":"slack"}', + ); + expect(response.status).toBe(503); + expect((await response.json()).code).toBe("identity_unavailable"); +}); From dacc12ed614a581daecb312211bbd0e56a7ec8ea Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:46:24 -0300 Subject: [PATCH 067/101] feat(server): link Slack accounts from OpenTag link codes --- server/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/index.ts b/server/src/index.ts index f843ff077..f2c431ffb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -177,6 +177,7 @@ import { } from "./host-access/broker"; import { hostAccessTools } from "./host-access/tools"; import { retireIdentityLinks, retireOwnedAccounts } from "./identity/retire"; +import { slackCodeRedeemer } from "./identity/slack-redeem"; import { createIdentityStore } from "./identity/store"; import { createIntelligenceClient, @@ -3010,6 +3011,7 @@ const app = createApp( scopeFor: deliveryScopeFor, slack: deliveryProviders.slack, twilio: deliveryProviders.sms, + identity: slackCodeRedeemer(identityStore, bootAuditStore), }, }, memory: { store: personalMemoryStore, ingestion: memoryIngestion }, From 6f59867251d43dc1f6069e65c513cbd8e5700976 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:46:31 -0300 Subject: [PATCH 068/101] feat(app): link Slack from linked accounts --- .../components/settings/linked-accounts.tsx | 99 ++++++++++++++++++- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index 5707359a1..ae8489401 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -1,11 +1,20 @@ import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { PageEmpty, PageRows, PageSection, } from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Item, ItemActions, @@ -15,9 +24,14 @@ import { ItemTitle, } from "@/components/ui/item"; import { Separator } from "@/components/ui/separator"; -import { unlinkMutationOptions } from "@/lib/identity/mutations"; +import { + issueSlackCodeMutationOptions, + type SlackLinkCode, + unlinkMutationOptions, +} from "@/lib/identity/mutations"; import { type IdentityProviders, + identityKeys, identityProvidersQueryOptions, type LinkedAccount, linkedAccountsQueryOptions, @@ -49,8 +63,8 @@ export function providerIcon(provider: string) { /** * What the section draws: nothing, rows, an error, or rows with an error under them. * - * Hidden while loading and when nothing is linked and nothing can be (no identity store, or no - * provider available to link); shown empty when a provider is available. A + * Hidden until the list has loaded, and when nothing is linked and nothing can be (no identity + * store, or no provider available to link); shown empty when a provider is available. A * failed refetch keeps the rows already on screen — stale but visible — and says the list could not * be refreshed, so a removed account that is still drawn is never shown without that warning. An * error with no rows to keep, including over a cached empty list, shows on its own. @@ -81,6 +95,17 @@ export function linkedAccountsSectionState({ export function LinkedAccountsSection() { const links = useQuery(linkedAccountsQueryOptions()); const providers = useQuery(identityProvidersQueryOptions()); + const issueCode = useMutation(issueSlackCodeMutationOptions()); + const [issued, setIssued] = useState(null); + const [copied, setCopied] = useState(false); + const closeDialog = () => { + setIssued(null); + setCopied(false); + // A link may have completed while the dialog was open. + queryClient.invalidateQueries({ queryKey: identityKeys.links() }); + }; + // Wait for the list so people with links never see the empty state while /providers answers first. + if (links.isPending) return null; const state = linkedAccountsSectionState({ data: links.data, error: links.error, @@ -90,7 +115,24 @@ export function LinkedAccountsSection() { return ( {/* actions */}} + action={ +
+ {providers.data?.slack ? ( + + ) : null} +
+ } > {state.rows.length === 0 && !state.error ? ( No linked accounts yet. @@ -110,6 +152,55 @@ export function LinkedAccountsSection() { Your linked accounts could not be loaded: {state.error}

) : null} + {issueCode.error ? ( +

+ Could not create a Slack link code: {issueCode.error.message} +

+ ) : null} + { + if (!open) closeDialog(); + }} + > + + + Link your Slack account + + + {issued ? ( +
+

{issued.instruction}

+
+ + link {issued.code} + + +
+

+ The code works once and expires in 10 minutes. +

+
+ ) : null} +
+ + + +
+
); } From e0908aa66e579d6b9f5f9ad4922882d0a50e16f3 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:46:50 -0300 Subject: [PATCH 069/101] feat(server): finish GitHub account linking on the sign-in callback --- server/src/identity/github-callback.ts | 139 ++++++++ server/tests/identity-github-callback.test.ts | 328 ++++++++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 server/src/identity/github-callback.ts create mode 100644 server/tests/identity-github-callback.test.ts diff --git a/server/src/identity/github-callback.ts b/server/src/identity/github-callback.ts new file mode 100644 index 000000000..a1b156679 --- /dev/null +++ b/server/src/identity/github-callback.ts @@ -0,0 +1,139 @@ +import { Hono } from "hono"; +import { type AuditStore, recordAuditEvent } from "../audit"; +import type { AppVariables } from "../auth/guards"; +import { type CredentialStore, encryptSecret } from "../credentials"; +import { + exchangeGithubCode, + fetchGithubUser, + githubRedirectUri, + openGithubState, +} from "./github-oauth"; +import { GITHUB_DOTCOM_REALM } from "./providers"; +import type { IdentityStore } from "./store"; + +export type GithubCallbackDeps = { + clientId: string; + clientSecret: string; + publicUrl: string; + appUrl: string | undefined; + encryptionKey: string; + personIsActive(userId: string): Promise; + credentials: Pick< + CredentialStore, + "create" | "rotate" | "revoke" | "findLiveByKey" + >; + identity: Pick; + auditStore?: AuditStore; + fetchImpl?: typeof fetch; + now?: () => Date; +}; + +/** + * Finishes linking a GitHub account when GitHub sends the person back. + * + * Every exit is a redirect to the connected-accounts page, `?linked=github` or `?linked=failed`; + * nothing the vendor or the request carried (code, tokens, secret, state) is logged or returned. + */ +export function githubCallbackRoutes( + deps: GithubCallbackDeps, +): Hono<{ Variables: AppVariables }> { + const routes = new Hono<{ Variables: AppVariables }>(); + const base = `${(deps.appUrl ?? "").replace(/\/+$/, "")}/settings/connected-accounts?linked=`; + const now = deps.now ?? (() => new Date()); + + routes.get("/", async (context) => { + const failed = () => context.redirect(`${base}failed`, 302); + try { + const code = context.req.query("code"); + if (context.req.query("error") || !code) return failed(); + + const state = await openGithubState( + context.req.query("state"), + deps.encryptionKey, + now(), + ); + if (!state) return failed(); + + // The signed-in session must be the person who started the link; otherwise a link started by + // one person could attach another person's GitHub account to this session (login CSRF). + const sessionUserId = context.var.actor?.id; + if (!sessionUserId || sessionUserId !== state.userId) return failed(); + + if (!(await deps.personIsActive(state.userId))) return failed(); + + const tokens = await exchangeGithubCode( + { + clientId: deps.clientId, + clientSecret: deps.clientSecret, + code, + redirectUri: githubRedirectUri(deps.publicUrl), + }, + deps.fetchImpl, + now(), + ); + const user = await fetchGithubUser(tokens.accessToken, deps.fetchImpl); + + const key = { + kind: "connector" as const, + provider: "github-user-token", + keyId: `${state.userId}:${user.id}`, + }; + const value = { + ...key, + metadata: { login: user.login }, + encryptedValue: await encryptSecret( + deps.encryptionKey, + JSON.stringify(tokens), + ), + }; + const live = await deps.credentials.findLiveByKey(key); + const stored = live + ? await deps.credentials.rotate({ + ...value, + previousCredentialId: live.id, + }) + : await deps.credentials.create(value); + + let link: Awaited>; + try { + link = await deps.identity.linkVerified( + { + provider: "github", + realm: GITHUB_DOTCOM_REALM, + subject: String(user.id), + }, + state.userId, + { method: "oauth", handle: user.login, credentialId: stored.id }, + ); + } catch (error) { + try { + await deps.credentials.revoke(stored.id); + } catch { + // Already revoked or unreachable: the redirect below is the answer either way. + } + throw error; + } + + if (deps.auditStore) { + await recordAuditEvent(deps.auditStore, { + eventType: "identity.linked", + targetType: "identity_link", + targetId: link.id, + actorUserId: state.userId, + payload: { actor: state.userId, provider: "github" }, + }); + } + return context.redirect(`${base}github`, 302); + } catch (error) { + console.error( + JSON.stringify({ + type: "identity-github-callback-failed", + reason: error instanceof Error ? error.name : "unknown", + }), + ); + return failed(); + } + }); + + return routes; +} diff --git a/server/tests/identity-github-callback.test.ts b/server/tests/identity-github-callback.test.ts new file mode 100644 index 000000000..f401a8112 --- /dev/null +++ b/server/tests/identity-github-callback.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { Hono } from "hono"; +import type { AuditEventInput } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { seal } from "../src/auth/signed-value"; +import { decryptSecret } from "../src/credentials"; +import { githubCallbackRoutes } from "../src/identity/github-callback"; +import { sealGithubState } from "../src/identity/github-oauth"; +import { IdentityConflictError } from "../src/identity/types"; + +const key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const now = new Date("2026-01-01T00:00:00.000Z"); +const clientSecret = "client-secret-value"; +const OK = "https://app.test/settings/connected-accounts?linked=github"; +const FAILED = "https://app.test/settings/connected-accounts?linked=failed"; + +type Harness = ReturnType; + +function build( + options: { + actorId?: string | null; + active?: boolean; + live?: { id: string } | null; + tokenFails?: boolean; + linkError?: Error; + revokeError?: Error; + nowAt?: Date; + } = {}, +) { + const fetchCalls: string[] = []; + const created: unknown[] = []; + const rotated: unknown[] = []; + const revoked: string[] = []; + const linked: unknown[][] = []; + const audits: AuditEventInput[] = []; + let activeChecks = 0; + + const fetchImpl = (async (url: string | URL | Request) => { + fetchCalls.push(String(url)); + if (String(url).includes("access_token")) { + if (options.tokenFails) return new Response("{}", { status: 400 }); + return Response.json({ + access_token: "gho_secret", + refresh_token: "ghr_secret", + expires_in: 28800, + refresh_token_expires_in: 15897600, + }); + } + return Response.json({ id: 42, login: "dana" }); + }) as unknown as typeof fetch; + + const routes = githubCallbackRoutes({ + clientId: "client-id", + clientSecret, + publicUrl: "https://o.test", + appUrl: "https://app.test/", + encryptionKey: key, + personIsActive: async () => { + activeChecks += 1; + return options.active ?? true; + }, + credentials: { + create: async (value) => { + created.push(value); + return { id: "cred-new", revokedAt: null }; + }, + rotate: async (value) => { + rotated.push(value); + return { id: "cred-new", revokedAt: null }; + }, + revoke: async (id) => { + revoked.push(id); + if (options.revokeError) throw options.revokeError; + return new Date(); + }, + findLiveByKey: async () => options.live ?? null, + }, + identity: { + linkVerified: async (...args: unknown[]) => { + linked.push(args); + if (options.linkError) throw options.linkError; + return { id: "link-1", userId: "person" } as never; + }, + }, + auditStore: { insert: async (event) => void audits.push(event) }, + fetchImpl, + now: () => options.nowAt ?? now, + }); + + const app = new Hono<{ Variables: AppVariables }>(); + app.use("*", async (context, next) => { + const id = options.actorId === undefined ? "person" : options.actorId; + if (id !== null) { + context.set("actor", { id, email: "x@y.z", role: "user" } as never); + } + await next(); + }); + app.route("/", routes); + + return { + app, + fetchCalls, + created, + rotated, + revoked, + linked, + audits, + activeChecks: () => activeChecks, + }; +} + +async function call(harness: Harness, query: string) { + const response = await harness.app.request(`/?${query}`); + return response.headers.get("location"); +} + +async function validState(userId = "person") { + return encodeURIComponent(await sealGithubState(userId, key, now)); +} + +let errors: ReturnType; +let warns: ReturnType; +let logs: ReturnType; +beforeEach(() => { + errors = spyOn(console, "error").mockImplementation(() => {}); + warns = spyOn(console, "warn").mockImplementation(() => {}); + logs = spyOn(console, "log").mockImplementation(() => {}); +}); +afterEach(() => { + errors.mockRestore(); + warns.mockRestore(); + logs.mockRestore(); +}); + +function output() { + return [errors, warns, logs] + .flatMap((spy) => spy.mock.calls) + .map((args: unknown[]) => args.map(String).join(" ")) + .join("\n"); +} + +describe("GitHub sign-in callback", () => { + test("links the account, stores the token and audits", async () => { + const harness = build(); + const response = await harness.app.request( + `/?code=the-code&state=${await validState()}`, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(OK); + + expect(harness.created).toHaveLength(1); + const stored = harness.created[0] as { + kind: string; + provider: string; + keyId: string; + metadata: unknown; + encryptedValue: string; + }; + expect(stored).toMatchObject({ + kind: "connector", + provider: "github-user-token", + keyId: "person:42", + metadata: { login: "dana" }, + }); + expect(JSON.parse(await decryptSecret(key, stored.encryptedValue))).toEqual( + { + accessToken: "gho_secret", + refreshToken: "ghr_secret", + expiresAt: "2026-01-01T08:00:00.000Z", + refreshTokenExpiresAt: new Date( + now.getTime() + 15897600 * 1000, + ).toISOString(), + }, + ); + + expect(harness.linked).toEqual([ + [ + { provider: "github", realm: "github.com", subject: "42" }, + "person", + { method: "oauth", handle: "dana", credentialId: "cred-new" }, + ], + ]); + expect(harness.audits).toEqual([ + { + eventType: "identity.linked", + targetType: "identity_link", + targetId: "link-1", + actorUserId: "person", + payload: { actor: "person", provider: "github" }, + }, + ]); + }); + + test("a reconnect rotates the live credential instead of inserting", async () => { + const harness = build({ live: { id: "cred-old" } }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(OK); + expect(harness.created).toHaveLength(0); + expect(harness.rotated).toHaveLength(1); + expect(harness.rotated[0]).toMatchObject({ + previousCredentialId: "cred-old", + keyId: "person:42", + }); + }); + + test("a denied authorization fails without calling GitHub", async () => { + const harness = build(); + expect( + await call(harness, `error=access_denied&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.fetchCalls).toHaveLength(0); + }); + + test("no code fails", async () => { + const harness = build(); + expect(await call(harness, `state=${await validState()}`)).toBe(FAILED); + expect(harness.fetchCalls).toHaveLength(0); + }); + + test("missing, expired and foreign-label states fail without a fetch", async () => { + const missing = build(); + expect(await call(missing, "code=the-code")).toBe(FAILED); + expect(missing.fetchCalls).toHaveLength(0); + + const expired = build({ nowAt: new Date(now.getTime() + 11 * 60_000) }); + expect( + await call(expired, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(expired.fetchCalls).toHaveLength(0); + + const foreign = build(); + const state = encodeURIComponent( + await seal( + JSON.stringify({ userId: "person", issuedAt: now.toISOString() }), + key, + "some-other-label", + ), + ); + expect(await call(foreign, `code=the-code&state=${state}`)).toBe(FAILED); + expect(foreign.fetchCalls).toHaveLength(0); + }); + + test("a session belonging to another person fails before anything else", async () => { + const harness = build({ actorId: "someone-else" }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.fetchCalls).toHaveLength(0); + expect(harness.activeChecks()).toBe(0); + expect(harness.created).toHaveLength(0); + }); + + test("no session fails", async () => { + const harness = build({ actorId: null }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.fetchCalls).toHaveLength(0); + expect(harness.activeChecks()).toBe(0); + }); + + test("an inactive person fails without a fetch", async () => { + const harness = build({ active: false }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.fetchCalls).toHaveLength(0); + }); + + test("a refused token exchange stores nothing", async () => { + const harness = build({ tokenFails: true }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.created).toHaveLength(0); + expect(harness.rotated).toHaveLength(0); + expect(harness.linked).toHaveLength(0); + }); + + test("an identity conflict revokes the new credential and does not audit", async () => { + const harness = build({ + linkError: new IdentityConflictError(), + }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.revoked).toEqual(["cred-new"]); + expect(harness.audits).toHaveLength(0); + }); + + test("a revoke that throws still redirects failed", async () => { + const harness = build({ + linkError: new IdentityConflictError(), + revokeError: new Error("already revoked"), + }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.audits).toHaveLength(0); + }); + + test("nothing secret is ever logged", async () => { + const state = await validState(); + for (const harness of [ + build(), + build({ tokenFails: true }), + build({ linkError: new IdentityConflictError() }), + build({ + linkError: new Error("boom gho_secret"), + revokeError: new Error("x"), + }), + ]) { + await call(harness, `code=the-code&state=${state}`); + } + const text = output(); + for (const secret of [ + "the-code", + "gho_secret", + "ghr_secret", + clientSecret, + decodeURIComponent(state), + state, + ]) { + expect(text).not.toContain(secret); + } + }); +}); From b04761c078c36a19eda0054269ee18014a17533f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:48:58 -0300 Subject: [PATCH 070/101] feat(server): start GitHub sign-in from identity routes --- server/src/identity/routes.ts | 23 ++++++++++++ server/tests/identity-routes.test.ts | 53 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/server/src/identity/routes.ts b/server/src/identity/routes.ts index f2059168b..4d53c0af7 100644 --- a/server/src/identity/routes.ts +++ b/server/src/identity/routes.ts @@ -2,6 +2,11 @@ import { Hono, type MiddlewareHandler } from "hono"; import { z } from "zod"; import { type AuditStore, recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; +import { + githubAuthorizationUrl, + githubRedirectUri, + sealGithubState, +} from "./github-oauth"; import { PROVIDERS } from "./providers"; import type { IdentityStore } from "./store"; @@ -89,6 +94,24 @@ export function identityRoutes( }); }); + app.post("/github/connect", async (context) => { + const github = options.github; + if (!github) + return context.json( + { + error: "That account type cannot be linked here.", + code: "identity_provider_unavailable", + }, + 404, + ); + const authorizationUrl = githubAuthorizationUrl({ + clientId: github.clientId, + redirectUri: githubRedirectUri(github.publicUrl), + state: await sealGithubState(context.var.actor.id, github.encryptionKey), + }); + return context.json({ connect: { authorizationUrl } }); + }); + app.get("/links", async (context) => { const links = await live.identitiesFor(context.var.actor.id); return context.json({ diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts index 4c8043e91..848326687 100644 --- a/server/tests/identity-routes.test.ts +++ b/server/tests/identity-routes.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import type { MiddlewareHandler } from "hono"; import type { AuditEventInput } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; +import { openGithubState } from "../src/identity/github-oauth"; import { type IdentityRouteOptions, identityRoutes, @@ -259,3 +260,55 @@ test("POST /challenges without a store is 503", async () => { expect(response.status).toBe(503); expect((await response.json()).code).toBe("identity_unavailable"); }); + +const githubKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const githubOptions = { + github: { + clientId: "Iv1.x", + publicUrl: "https://o.test", + encryptionKey: githubKey, + }, +}; + +test("POST /github/connect returns the GitHub authorization URL for the asker", async () => { + const response = await fixture("github", githubOptions).app.request( + "/github/connect", + { method: "POST" }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + connect: { authorizationUrl: string }; + }; + const url = new URL(body.connect.authorizationUrl); + expect(url.origin + url.pathname).toBe( + "https://github.com/login/oauth/authorize", + ); + expect(url.searchParams.get("client_id")).toBe("Iv1.x"); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://o.test/api/identity/github/callback", + ); + expect( + await openGithubState(url.searchParams.get("state") ?? "", githubKey), + ).toEqual({ userId: "person" }); +}); + +test("POST /github/connect is 404 unless GitHub is configured", async () => { + const f = fixture("github"); + const response = await f.app.request("/github/connect", { method: "POST" }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: expect.any(String), + code: "identity_provider_unavailable", + }); +}); + +test("POST /github/connect without a store is 503", async () => { + const response = await identityRoutes( + signedIn, + undefined, + undefined, + githubOptions, + ).request("/github/connect", { method: "POST" }); + expect(response.status).toBe(503); + expect((await response.json()).code).toBe("identity_unavailable"); +}); From 1d350f55689d10126c3450e55afd84429380b9db Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:49:02 -0300 Subject: [PATCH 071/101] feat(app): connect GitHub from linked accounts --- .../components/settings/linked-accounts.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index ae8489401..ca64e4cf0 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/item"; import { Separator } from "@/components/ui/separator"; import { + connectGithubMutationOptions, issueSlackCodeMutationOptions, type SlackLinkCode, unlinkMutationOptions, @@ -96,6 +97,7 @@ export function LinkedAccountsSection() { const links = useQuery(linkedAccountsQueryOptions()); const providers = useQuery(identityProvidersQueryOptions()); const issueCode = useMutation(issueSlackCodeMutationOptions()); + const connectGithub = useMutation(connectGithubMutationOptions()); const [issued, setIssued] = useState(null); const [copied, setCopied] = useState(false); const closeDialog = () => { @@ -131,6 +133,20 @@ export function LinkedAccountsSection() { Link Slack ) : null} + {providers.data?.github ? ( + + ) : null} } > @@ -157,6 +173,11 @@ export function LinkedAccountsSection() { Could not create a Slack link code: {issueCode.error.message}

) : null} + {connectGithub.error ? ( +

+ Could not connect GitHub: {connectGithub.error.message} +

+ ) : null} { From a0453b92dcd8e26f39f7ef8e0abcc745006a320c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:49:11 -0300 Subject: [PATCH 072/101] test(server): cover GitHub linking against the database --- ...entity-github-callback.integration.test.ts | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 server/tests/identity-github-callback.integration.test.ts diff --git a/server/tests/identity-github-callback.integration.test.ts b/server/tests/identity-github-callback.integration.test.ts new file mode 100644 index 000000000..2884672c8 --- /dev/null +++ b/server/tests/identity-github-callback.integration.test.ts @@ -0,0 +1,220 @@ +import { afterAll, expect, test } from "bun:test"; +import { randomInt, randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import { Hono } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import { createCredentialStore, decryptSecret } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { credentials, identityLinks } from "../src/db/schema"; +import { githubCallbackRoutes } from "../src/identity/github-callback"; +import { sealGithubState } from "../src/identity/github-oauth"; +import { createIdentityStore } from "../src/identity/store"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const encryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const OK = "https://app.test/settings/connected-accounts?linked=github"; +const FAILED = "https://app.test/settings/connected-accounts?linked=failed"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const vault = createCredentialStore(database); +const identity = createIdentityStore(database, vault); +const usedSubjects: string[] = []; +const usedKeyIds: string[] = []; + +afterAll(async () => { + if (usedSubjects.length) + await database + .delete(identityLinks) + .where(inArray(identityLinks.subject, usedSubjects)); + if (usedKeyIds.length) + await database + .delete(credentials) + .where(inArray(credentials.keyId, usedKeyIds)); + await database.$client.close(); +}); + +const person = () => `user-${randomUUID()}`; + +function githubAccount() { + const id = randomInt(100_000_000, 1_000_000_000); + usedSubjects.push(String(id)); + return { id, login: `login-${id}` }; +} + +function harness(account: { id: number; login: string }, token: string) { + const fetchCalls: string[] = []; + const fetchImpl = (async (url: string | URL | Request) => { + fetchCalls.push(String(url)); + if (String(url).includes("access_token")) { + return Response.json({ + access_token: token, + refresh_token: `${token}-refresh`, + expires_in: 28800, + refresh_token_expires_in: 15897600, + }); + } + return Response.json(account); + }) as unknown as typeof fetch; + + const callback = githubCallbackRoutes({ + clientId: "client-id", + clientSecret: "client-secret-value", + publicUrl: "https://o.test", + appUrl: "https://app.test/", + encryptionKey, + personIsActive: async () => true, + credentials: vault, + identity, + fetchImpl, + }); + + // Runs the callback as `sessionUser`, with a state sealed for `stateUser`. + async function run(sessionUser: string, stateUser: string) { + const app = new Hono<{ Variables: AppVariables }>(); + app.use("*", async (context, next) => { + context.set("actor", { id: sessionUser } as AppVariables["actor"]); + await next(); + }); + app.route("/", callback); + const state = await sealGithubState(stateUser, encryptionKey); + return app.request( + `/?code=code-${randomUUID()}&state=${encodeURIComponent(state)}`, + ); + } + + return { run, fetchCalls }; +} + +const keyId = (userId: string, account: { id: number }) => { + const value = `${userId}:${account.id}`; + usedKeyIds.push(value); + return value; +}; + +const linkFor = async (account: { id: number }) => { + const [row] = await database + .select() + .from(identityLinks) + .where( + and( + eq(identityLinks.provider, "github"), + eq(identityLinks.realm, "github.com"), + eq(identityLinks.subject, String(account.id)), + ), + ); + return row; +}; + +const credentialById = async (id: string | null | undefined) => { + const [row] = await database + .select() + .from(credentials) + .where(eq(credentials.id, id ?? "")); + return row; +}; + +test("a first connection links the account and stores a live token", async () => { + const account = githubAccount(); + const user = person(); + keyId(user, account); + const { run } = harness(account, "gho_first"); + + const response = await run(user, user); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(OK); + const link = await linkFor(account); + expect(link).toMatchObject({ + provider: "github", + realm: "github.com", + subject: String(account.id), + userId: user, + verifiedBy: "oauth", + handle: account.login, + }); + const stored = await credentialById(link?.credentialId); + expect(stored).toMatchObject({ + kind: "connector", + provider: "github-user-token", + revokedAt: null, + }); + const tokens = JSON.parse( + await decryptSecret(encryptionKey, stored?.encryptedValue ?? ""), + ); + expect(tokens.accessToken).toBe("gho_first"); +}); + +test("connecting again rotates the credential and revokes the previous one", async () => { + const account = githubAccount(); + const user = person(); + keyId(user, account); + + const first = await harness(account, "gho_one").run(user, user); + expect(first.headers.get("location")).toBe(OK); + const firstCredentialId = (await linkFor(account))?.credentialId; + + const second = await harness(account, "gho_two").run(user, user); + + expect(second.status).toBe(302); + expect(second.headers.get("location")).toBe(OK); + const link = await linkFor(account); + expect(link?.credentialId).not.toBe(firstCredentialId); + const current = await credentialById(link?.credentialId); + expect(current?.revokedAt).toBeNull(); + const tokens = JSON.parse( + await decryptSecret(encryptionKey, current?.encryptedValue ?? ""), + ); + expect(tokens.accessToken).toBe("gho_two"); + expect((await credentialById(firstCredentialId))?.revokedAt).not.toBeNull(); +}); + +test("an account already linked to someone else fails and revokes the new token", async () => { + const account = githubAccount(); + const owner = person(); + const intruder = person(); + keyId(owner, account); + const intruderKey = keyId(intruder, account); + + const claimed = await harness(account, "gho_owner").run(owner, owner); + expect(claimed.headers.get("location")).toBe(OK); + const before = await linkFor(account); + + const response = await harness(account, "gho_intruder").run( + intruder, + intruder, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(FAILED); + const after = await linkFor(account); + expect(after).toMatchObject({ + userId: owner, + credentialId: before?.credentialId, + }); + const [stranded] = await database + .select() + .from(credentials) + .where(eq(credentials.keyId, intruderKey)); + expect(stranded?.revokedAt).not.toBeNull(); +}); + +test("a state sealed for another person fails before GitHub is called", async () => { + const account = githubAccount(); + const starter = person(); + const session = person(); + const starterKey = keyId(starter, account); + const sessionKey = keyId(session, account); + const { run, fetchCalls } = harness(account, "gho_never"); + + const response = await run(session, starter); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(FAILED); + expect(fetchCalls).toEqual([]); + expect(await linkFor(account)).toBeUndefined(); + const stored = await database + .select() + .from(credentials) + .where(inArray(credentials.keyId, [starterKey, sessionKey])); + expect(stored).toEqual([]); +}); From 9de8e0cfdbc455709e1f8a2f4c37dd7fd8ecaae1 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:50:58 -0300 Subject: [PATCH 073/101] feat(server): offer Slack and GitHub linking where they are configured --- server/src/app.ts | 15 +++- server/tests/identity-app-wiring.test.ts | 94 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 server/tests/identity-app-wiring.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 77941f983..cf58f6833 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -685,7 +685,20 @@ export function createApp( * A person's own linked accounts at outside providers (Slack, GitHub). Theirs alone: there is no * administrator view and no linking on somebody else's behalf. */ - app.route("/api/identity", identityRoutes(requireUser, identity, auditStore)); + app.route( + "/api/identity", + identityRoutes(requireUser, identity, auditStore, { + slackLinking: Boolean(coworker?.delivery?.webhooks.slack), + github: + config.githubApp && config.publicUrl + ? { + clientId: config.githubApp.clientId, + publicUrl: config.publicUrl, + encryptionKey: config.keyEncryptionKey, + } + : undefined, + }), + ); app.get("/api/settings/instructions", requireUser, async (context) => { if (!userInstructions) { diff --git a/server/tests/identity-app-wiring.test.ts b/server/tests/identity-app-wiring.test.ts new file mode 100644 index 000000000..d4cfdfb9c --- /dev/null +++ b/server/tests/identity-app-wiring.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import { testEnvironment } from "./support/environment"; + +const SESSION = { + user: { + id: "person", + email: "person@openbot.test", + name: "Person", + image: null, + }, +}; + +const GITHUB_ENVIRONMENT = { + GITHUB_APP_CLIENT_ID: "Iv1.x", + GITHUB_APP_CLIENT_SECRET: "s", + OPENBOT_PUBLIC_URL: "https://api.test", + OPENBOT_APP_URL: "https://app.test", +}; + +/** Builds the app with only what the identity routes need; later tests can extend the options. */ +function appFor( + environment: Record, + options: { session?: typeof SESSION | null; coworker?: unknown } = {}, +) { + const session = options.session === undefined ? SESSION : options.session; + const identity = { + identitiesFor: async () => [], + unlink: async () => false, + issueChallenge: async () => ({ code: "code", expiresAt: new Date() }), + }; + return createApp( + loadConfig(testEnvironment(environment)), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => session }, + } as never, + { rolesForUser: async () => ["user"] }, + ...(Array.from({ length: 9 }) as never[]), + undefined as never, + ...(Array.from({ length: 21 }) as never[]), + options.coworker as never, + undefined as never, + identity as never, + ); +} + +test("identity routes require a session", async () => { + const response = await appFor(GITHUB_ENVIRONMENT, { + session: null, + }).request("http://openbot.test/api/identity/providers"); + expect(response.status).toBe(401); +}); + +test("GitHub is offered when the app and a public URL are configured", async () => { + const response = await appFor(GITHUB_ENVIRONMENT).request( + "http://openbot.test/api/identity/providers", + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + providers: { slack: false, github: true }, + }); +}); + +test("connecting GitHub redirects back to this deployment's callback", async () => { + const response = await appFor(GITHUB_ENVIRONMENT).request( + "http://openbot.test/api/identity/github/connect", + { method: "POST" }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + connect: { authorizationUrl: string }; + }; + const url = new URL(body.connect.authorizationUrl); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://api.test/api/identity/github/callback", + ); +}); + +test("without a GitHub app, GitHub is not offered and cannot be connected", async () => { + const app = appFor({}); + const providers = await app.request( + "http://openbot.test/api/identity/providers", + ); + expect(await providers.json()).toEqual({ + providers: { slack: false, github: false }, + }); + const connect = await app.request( + "http://openbot.test/api/identity/github/connect", + { method: "POST" }, + ); + expect(connect.status).toBe(404); +}); From 84f408cdb6fad07ca48af898fc456479e6efacee Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:51:46 -0300 Subject: [PATCH 074/101] feat(server): mount the GitHub sign-in callback behind the session --- server/src/app.ts | 12 ++++ server/tests/identity-app-wiring.test.ts | 72 +++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index cf58f6833..cd547edd0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -425,6 +425,8 @@ export function createApp( selfHostBanner?: SelfHostBanner, /** A person's own linked accounts. Absent answers 503 on /api/identity. */ identity?: IdentityStore, + /** The GitHub sign-in callback, built by the caller; mounted behind requireUser ahead of /api/identity. */ + identityGithubCallback?: Hono<{ Variables: AppVariables }>, ) { const app = new Hono<{ Variables: AppVariables }>(); mountDesktopConnectionFailure(app, desktopHostToken); @@ -681,6 +683,16 @@ export function createApp( userPreferencesRoutes(requireUser, userPreferences), ); + /* + * The session must match the sealed state's person, otherwise a link started by one person could + * attach another person's GitHub account (login CSRF), so the callback requires the same session + * as the rest of /api/identity; a missing session answers 401 rather than a redirect. Mounted + * before the identity router so that router never sees this path. + */ + if (identityGithubCallback) { + app.use("/api/identity/github/callback", requireUser); + app.route("/api/identity/github/callback", identityGithubCallback); + } /* * A person's own linked accounts at outside providers (Slack, GitHub). Theirs alone: there is no * administrator view and no linking on somebody else's behalf. diff --git a/server/tests/identity-app-wiring.test.ts b/server/tests/identity-app-wiring.test.ts index d4cfdfb9c..d77c2e978 100644 --- a/server/tests/identity-app-wiring.test.ts +++ b/server/tests/identity-app-wiring.test.ts @@ -1,6 +1,8 @@ -import { expect, test } from "bun:test"; +import { expect, mock, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; +import { githubCallbackRoutes } from "../src/identity/github-callback"; +import { sealGithubState } from "../src/identity/github-oauth"; import { testEnvironment } from "./support/environment"; const SESSION = { @@ -22,7 +24,11 @@ const GITHUB_ENVIRONMENT = { /** Builds the app with only what the identity routes need; later tests can extend the options. */ function appFor( environment: Record, - options: { session?: typeof SESSION | null; coworker?: unknown } = {}, + options: { + session?: typeof SESSION | null; + coworker?: unknown; + githubCallback?: unknown; + } = {}, ) { const session = options.session === undefined ? SESSION : options.session; const identity = { @@ -43,9 +49,43 @@ function appFor( options.coworker as never, undefined as never, identity as never, + options.githubCallback as never, ); } +/** A real GitHub callback router whose every dependency is a fake; the fetch stub records any call. */ +function callbackFor(environment: Record) { + const fetchImpl = mock(async () => new Response("{}", { status: 500 })); + const encryptionKey = loadConfig( + testEnvironment(environment), + ).keyEncryptionKey; + const router = githubCallbackRoutes({ + clientId: "Iv1.x", + clientSecret: "s", + publicUrl: "https://api.test", + appUrl: "https://app.test", + encryptionKey, + personIsActive: async () => true, + credentials: { + create: async () => { + throw new Error("unexpected"); + }, + rotate: async () => { + throw new Error("unexpected"); + }, + revoke: async () => undefined, + findLiveByKey: async () => undefined, + } as never, + identity: { + linkVerified: async () => { + throw new Error("unexpected"); + }, + } as never, + fetchImpl: fetchImpl as never, + }); + return { router, fetchImpl, encryptionKey }; +} + test("identity routes require a session", async () => { const response = await appFor(GITHUB_ENVIRONMENT, { session: null, @@ -92,3 +132,31 @@ test("without a GitHub app, GitHub is not offered and cannot be connected", asyn ); expect(connect.status).toBe(404); }); + +test("the GitHub callback without a session answers 401 and never reaches GitHub", async () => { + const { router, fetchImpl, encryptionKey } = callbackFor(GITHUB_ENVIRONMENT); + const state = await sealGithubState("person", encryptionKey); + const response = await appFor(GITHUB_ENVIRONMENT, { + session: null, + githubCallback: router, + }).request( + `http://openbot.test/api/identity/github/callback?code=c&state=${encodeURIComponent(state)}`, + ); + expect(response.status).toBe(401); + expect(fetchImpl).not.toHaveBeenCalled(); +}); + +test("the GitHub callback is reachable with a session and redirects to the app", async () => { + const { router, fetchImpl, encryptionKey } = callbackFor(GITHUB_ENVIRONMENT); + const state = await sealGithubState("person", encryptionKey); + const response = await appFor(GITHUB_ENVIRONMENT, { + githubCallback: router, + }).request( + `http://openbot.test/api/identity/github/callback?error=access_denied&state=${encodeURIComponent(state)}`, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://app.test/settings/connected-accounts?linked=failed", + ); + expect(fetchImpl).not.toHaveBeenCalled(); +}); From 9125b79360faa02462111e0ca880b3e7afae080f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:52:20 -0300 Subject: [PATCH 075/101] feat(server): wire the GitHub sign-in callback at startup --- server/src/index.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/src/index.ts b/server/src/index.ts index f2c431ffb..4b802c69b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -176,6 +176,7 @@ import { HostAccessRefusedError, } from "./host-access/broker"; import { hostAccessTools } from "./host-access/tools"; +import { githubCallbackRoutes } from "./identity/github-callback"; import { retireIdentityLinks, retireOwnedAccounts } from "./identity/retire"; import { slackCodeRedeemer } from "./identity/slack-redeem"; import { createIdentityStore } from "./identity/store"; @@ -2857,6 +2858,24 @@ const selfHostBannerIntelligence = createIntelligenceClient( config.runtime.intelligence, ); +const identityGithubCallback = + config.githubApp && config.publicUrl + ? githubCallbackRoutes({ + clientId: config.githubApp.clientId, + clientSecret: config.githubApp.clientSecret, + publicUrl: config.publicUrl, + appUrl: config.appUrl, + encryptionKey: config.keyEncryptionKey, + personIsActive: async (userId) => { + const person = await peopleStore.find(userId); + return person !== undefined && !person.revoked; + }, + credentials: credentialStore, + identity: identityStore, + auditStore: bootAuditStore, + }) + : undefined; + const app = createApp( config, auth, @@ -3043,6 +3062,7 @@ const app = createApp( }), identityStore, + identityGithubCallback, ); /** From 8c7131b6de8cf467bfb330569255ad400114f2a5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 15:55:32 -0300 Subject: [PATCH 076/101] test(server): pass the self-host banner at its own createApp position --- server/tests/self-host-banner.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/tests/self-host-banner.test.ts b/server/tests/self-host-banner.test.ts index db0b22f72..fc503ea94 100644 --- a/server/tests/self-host-banner.test.ts +++ b/server/tests/self-host-banner.test.ts @@ -209,11 +209,13 @@ describe("the self-host banner", () => { describe("the capabilities endpoint", () => { const config = loadConfig(testEnvironment()); - // createApp takes its services positionally, and the banner is the last of them. + // createApp takes its services positionally. The banner is the 36th, followed by the identity + // store and the GitHub sign-in callback, so it is no longer the last of them. + const SELF_HOST_BANNER_ARG = 35; function appWith(banner: ReturnType) { const args: unknown[] = new Array(createApp.length).fill(undefined); args[0] = config; - args[args.length - 1] = banner; + args[SELF_HOST_BANNER_ARG] = banner; return (createApp as (...input: unknown[]) => ReturnType)( ...args, ); From 0a0f31f51b7ab12176caeb2d88463b028c58fbc2 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:23:51 -0300 Subject: [PATCH 077/101] feat(app): show each linkable account type as its own card --- .../settings/linked-accounts.test.tsx | 104 ++++++++--- .../components/settings/linked-accounts.tsx | 165 +++++++++++------- 2 files changed, 181 insertions(+), 88 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index 429cbfb53..c886f343f 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -112,58 +112,104 @@ test("a 200 whose body has no links array throws the fallback", async () => { }); const row = { ...base, handle: "dana", status: "active" as const }; +const slackRow = { + ...base, + id: "2", + provider: "slack" as const, + title: "Slack", + handle: null, + status: "active" as const, +}; +const none = { slack: false, github: false }; -test("the section hides while loading and when nothing is linked", () => { - expect(linkedAccountsSectionState({ data: undefined, error: null })).toBe( - null, - ); - expect(linkedAccountsSectionState({ data: [], error: null })).toBe(null); -}); - -test("the section shows an empty list whenever linking is available", () => { - for (const providers of [ - { slack: true, github: false }, - { slack: false, github: true }, - ]) { - for (const data of [undefined, []]) { - expect( - linkedAccountsSectionState({ data, error: null, providers }), - ).toEqual({ rows: [], error: null }); - } +test("the section hides when nothing is linked and nothing can be", () => { + for (const providers of [undefined, none]) { + expect( + linkedAccountsSectionState({ data: [], error: null, providers }), + ).toBe(null); + expect( + linkedAccountsSectionState({ data: undefined, error: null, providers }), + ).toBe(null); } }); -test("the section stays hidden when no provider is available", () => { +test("each account type that can be linked gets its own card to connect from", () => { expect( linkedAccountsSectionState({ data: [], error: null, - providers: { slack: false, github: false }, + providers: { slack: true, github: true }, }), - ).toBe(null); + ).toEqual({ + entries: [ + { kind: "available", provider: "slack", title: "Slack" }, + { kind: "available", provider: "github", title: "GitHub" }, + ], + error: null, + }); + expect( + linkedAccountsSectionState({ + data: [], + error: null, + providers: { slack: false, github: true }, + }), + ).toEqual({ + entries: [{ kind: "available", provider: "github", title: "GitHub" }], + error: null, + }); +}); + +test("a linked account takes the place of its connect card", () => { expect( - linkedAccountsSectionState({ data: [], error: null, providers: undefined }), - ).toBe(null); + linkedAccountsSectionState({ + data: [row], + error: null, + providers: { slack: true, github: true }, + }), + ).toEqual({ + entries: [ + { kind: "available", provider: "slack", title: "Slack" }, + { kind: "linked", account: row }, + ], + error: null, + }); }); -test("rows show on their own when the list loaded", () => { - expect(linkedAccountsSectionState({ data: [row], error: null })).toEqual({ - rows: [row], +test("links stay listed, in Slack then GitHub order, even when linking is off", () => { + expect( + linkedAccountsSectionState({ + data: [row, slackRow], + error: null, + providers: none, + }), + ).toEqual({ + entries: [ + { kind: "linked", account: slackRow }, + { kind: "linked", account: row }, + ], error: null, }); }); test("a failed refetch keeps stale rows on screen and shows the error with them", () => { expect( - linkedAccountsSectionState({ data: [row], error: new Error("boom") }), - ).toEqual({ rows: [row], error: "boom" }); + linkedAccountsSectionState({ + data: [row], + error: new Error("boom"), + providers: none, + }), + ).toEqual({ entries: [{ kind: "linked", account: row }], error: "boom" }); }); test("an error with no data, or with an empty cached list, still shows", () => { for (const data of [undefined, []]) { expect( - linkedAccountsSectionState({ data, error: new Error("boom") }), - ).toEqual({ rows: [], error: "boom" }); + linkedAccountsSectionState({ + data, + error: new Error("boom"), + providers: none, + }), + ).toEqual({ entries: [], error: "boom" }); } }); diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index ca64e4cf0..eff0d307a 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -1,11 +1,7 @@ import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useState } from "react"; -import { - PageEmpty, - PageRows, - PageSection, -} from "@/components/layout/page-shell"; +import { PageRows, PageSection } from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -61,14 +57,29 @@ export function providerIcon(provider: string) { : IconLink; } +/** The account types a person can link from here, in the order their cards are drawn. */ +const linkableProviders = [ + { provider: "slack", title: "Slack" }, + { provider: "github", title: "GitHub" }, +] as const; + +export type LinkedAccountsEntry = + | { kind: "linked"; account: LinkedAccount } + | { + kind: "available"; + provider: (typeof linkableProviders)[number]["provider"]; + title: string; + }; + /** - * What the section draws: nothing, rows, an error, or rows with an error under them. + * What the section draws: one card per account type, then an error if the list failed. * - * Hidden until the list has loaded, and when nothing is linked and nothing can be (no identity - * store, or no provider available to link); shown empty when a provider is available. A - * failed refetch keeps the rows already on screen — stale but visible — and says the list could not - * be refreshed, so a removed account that is still drawn is never shown without that warning. An - * error with no rows to keep, including over a cached empty list, shows on its own. + * Each account type is either the account linked there, with its disconnect, or — when this + * deployment can link it — an unlinked card to connect from. Links stay listed even when their type + * can no longer be linked, so they can still be disconnected; links of a type this build does not + * know come last. Connect cards need the list: with no list loaded there is no knowing whether the + * account is already linked. Hidden when there is nothing to list, nothing to connect and nothing + * to report. A failed refetch keeps the stale entries on screen with the error under them. */ export function linkedAccountsSectionState({ data, @@ -78,13 +89,24 @@ export function linkedAccountsSectionState({ data: LinkedAccount[] | undefined; error: Error | null; providers?: IdentityProviders; -}): { rows: LinkedAccount[]; error: string | null } | null { - const rows = data ?? []; - if (error) return { rows, error: error.message }; - if (rows.length > 0 || providers?.slack || providers?.github) { - return { rows, error: null }; +}): { entries: LinkedAccountsEntry[]; error: string | null } | null { + const links = data ?? []; + const entries: LinkedAccountsEntry[] = []; + for (const { provider, title } of linkableProviders) { + const linked = links.filter((account) => account.provider === provider); + for (const account of linked) entries.push({ kind: "linked", account }); + if (linked.length === 0 && data !== undefined && providers?.[provider]) { + entries.push({ kind: "available", provider, title }); + } + } + for (const account of links) { + if ( + !linkableProviders.some(({ provider }) => provider === account.provider) + ) + entries.push({ kind: "linked", account }); } - return null; + if (entries.length === 0 && !error) return null; + return { entries, error: error ? error.message : null }; } /** @@ -114,51 +136,39 @@ export function LinkedAccountsSection() { providers: providers.data, }); if (!state) return null; + const connect = { + slack: { + pending: issueCode.isPending, + start: () => + issueCode.mutate(undefined, { onSuccess: (code) => setIssued(code) }), + }, + github: { + pending: connectGithub.isPending, + start: () => + connectGithub.mutate(undefined, { + onSuccess: (url) => window.location.assign(url), + }), + }, + }; return ( - - {providers.data?.slack ? ( - - ) : null} - {providers.data?.github ? ( - - ) : null} - - } - > - {state.rows.length === 0 && !state.error ? ( - No linked accounts yet. - ) : null} - {state.rows.length > 0 ? ( + + {state.entries.length > 0 ? ( - {state.rows.map((account, index) => ( -
+ {state.entries.map((entry, index) => ( +
{index > 0 ? : null} - + {entry.kind === "linked" ? ( + + ) : ( + + )}
))} @@ -226,6 +236,43 @@ export function LinkedAccountsSection() { ); } +/** An account type this deployment can link and the person has not linked yet. */ +function AvailableAccountRow({ + provider, + title, + pending, + onConnect, +}: { + provider: string; + title: string; + pending: boolean; + onConnect: () => void; +}) { + const Icon = providerIcon(provider); + return ( + + + + + + {title} + Not linked + + + + + + ); +} + /** * One linked account with its own disconnect, so a second click elsewhere neither re-enables this * row mid-request nor overwrites its error. From 09196adc9dc13e7e433dcf153045547634ebeedd Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:28:11 -0300 Subject: [PATCH 078/101] fix(server): accept Slack link codes only in direct messages --- server/src/delivery/opentag-agent.ts | 14 +++++++++++++- server/tests/delivery-routes.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 1e6109aac..9f2b46025 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -362,11 +362,23 @@ export async function answerOpenQuestion( async function link( deps: OpenTagAgentDeps, - sender: { transport: ChatTransport; realm: string; identity: string }, + sender: { + transport: ChatTransport; + realm: string; + identity: string; + private: boolean; + }, code: string, ) { const challenge = await deps.store.readChallenge(code); if (!challenge && sender.transport === "slack" && deps.identity) { + // Anyone else in a shared conversation could send the pasted code first and bind their own + // Slack account to its issuer, so an account code is only redeemed in a direct message and is + // left unspent here for its owner. + if (!sender.private) + return { + text: "Send your link code to me in a direct message, not in a channel. It still works until it expires.", + }; const outcome = await deps.identity.redeem(code, { teamId: sender.realm, userId: sender.identity, diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 156874001..ac438170e 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -282,6 +282,29 @@ test("an identity code from a Slack sender is redeemed through the adapter", asy ); expect(redeemed).toEqual([[IDENTITY_CODE, { teamId: "T1", userId: "U1" }]]); }); +test("an identity code sent where others can read it is not redeemed", async () => { + for (const conversation of [{ id: "C0123456789", kind: "channel" }, null]) { + const { f, redeemed } = identityFixture("linked"); + const result = await run(f, { + context: [sender("slack:T1:U1", conversation)], + ...linkMessage(IDENTITY_CODE), + }); + expect(said(result.events)).toBe( + "Send your link code to me in a direct message, not in a channel. It still works until it expires.", + ); + expect(redeemed).toEqual([]); + expect(f.calls).toEqual([]); + } +}); +test("a Reachability code still links from a shared conversation", async () => { + const { f, redeemed } = identityFixture("linked"); + const result = await run(f, { + context: [sender("slack:T1:U1", { id: "C0123456789", kind: "channel" })], + ...linkMessage("11111111-2222-4333-8444-555555555555"), + }); + expect(said(result.events)).toContain("Linked."); + expect(redeemed).toEqual([]); +}); test("an identity code conflict names the other user", async () => { const { f } = identityFixture("conflict"); const result = await run(f, { From fdbf6fbd4666c0d2a45c75dd2dd383a177d87200 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:28:23 -0300 Subject: [PATCH 079/101] fix(server): offer linking only when its return path is wired --- server/src/app.ts | 11 ++++-- server/tests/identity-app-wiring.test.ts | 46 ++++++++++++++++++++---- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index cd547edd0..b84dbe65c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -695,14 +695,19 @@ export function createApp( } /* * A person's own linked accounts at outside providers (Slack, GitHub). Theirs alone: there is no - * administrator view and no linking on somebody else's behalf. + * administrator view and no linking on somebody else's behalf. A provider is offered only when + * its return path is wired: Slack needs the identity redeemer for the codes it issues, and GitHub + * needs the callback mounted above, otherwise people would be sent away and back to nothing. */ app.route( "/api/identity", identityRoutes(requireUser, identity, auditStore, { - slackLinking: Boolean(coworker?.delivery?.webhooks.slack), + slackLinking: Boolean( + coworker?.delivery?.webhooks.slack && + coworker.delivery.webhooks.identity, + ), github: - config.githubApp && config.publicUrl + identityGithubCallback && config.githubApp && config.publicUrl ? { clientId: config.githubApp.clientId, publicUrl: config.publicUrl, diff --git a/server/tests/identity-app-wiring.test.ts b/server/tests/identity-app-wiring.test.ts index d77c2e978..40912ea38 100644 --- a/server/tests/identity-app-wiring.test.ts +++ b/server/tests/identity-app-wiring.test.ts @@ -94,9 +94,9 @@ test("identity routes require a session", async () => { }); test("GitHub is offered when the app and a public URL are configured", async () => { - const response = await appFor(GITHUB_ENVIRONMENT).request( - "http://openbot.test/api/identity/providers", - ); + const response = await appFor(GITHUB_ENVIRONMENT, { + githubCallback: callbackFor(GITHUB_ENVIRONMENT).router, + }).request("http://openbot.test/api/identity/providers"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ providers: { slack: false, github: true }, @@ -104,10 +104,11 @@ test("GitHub is offered when the app and a public URL are configured", async () }); test("connecting GitHub redirects back to this deployment's callback", async () => { - const response = await appFor(GITHUB_ENVIRONMENT).request( - "http://openbot.test/api/identity/github/connect", - { method: "POST" }, - ); + const response = await appFor(GITHUB_ENVIRONMENT, { + githubCallback: callbackFor(GITHUB_ENVIRONMENT).router, + }).request("http://openbot.test/api/identity/github/connect", { + method: "POST", + }); expect(response.status).toBe(200); const body = (await response.json()) as { connect: { authorizationUrl: string }; @@ -118,6 +119,37 @@ test("connecting GitHub redirects back to this deployment's callback", async () ); }); +test("configured GitHub is not offered while its callback is not mounted", async () => { + const app = appFor(GITHUB_ENVIRONMENT); + const providers = await app.request( + "http://openbot.test/api/identity/providers", + ); + expect(await providers.json()).toEqual({ + providers: { slack: false, github: false }, + }); + const connect = await app.request( + "http://openbot.test/api/identity/github/connect", + { method: "POST" }, + ); + expect(connect.status).toBe(404); +}); + +async function slackOffered(webhooks: Record) { + const response = await appFor( + {}, + { coworker: { delivery: { webhooks } } }, + ).request("http://openbot.test/api/identity/providers"); + const body = (await response.json()) as { providers: { slack: boolean } }; + return body.providers.slack; +} + +test("Slack linking needs both the Slack webhook and the identity redeemer", async () => { + const handler = async () => new Response(null, { status: 204 }); + expect(await slackOffered({ slack: handler })).toBe(false); + expect(await slackOffered({ identity: handler })).toBe(false); + expect(await slackOffered({ slack: handler, identity: handler })).toBe(true); +}); + test("without a GitHub app, GitHub is not offered and cannot be connected", async () => { const app = appFor({}); const providers = await app.request( From eeddc21f2dd280caf60818bf4da4eba019d10212 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:28:29 -0300 Subject: [PATCH 080/101] fix(server): refuse a GitHub App without a public URL at start-up --- server/src/config.ts | 14 ++++++++--- server/tests/config-github-app.test.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index d8196bea5..1e20ee640 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1298,6 +1298,16 @@ export function loadConfig( : undefined; const managedAgent = managedAgentConfig(environment); const githubApp = githubAppConfig(environment); + const publicUrl = ( + optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl + )?.replace(/\/+$/, ""); + // GitHub sends people back to this address after they approve. Without it linking is silently + // switched off, so say so at start-up instead. + if (githubApp && !publicUrl) { + throw new Error( + "GITHUB_APP_CLIENT_ID needs OPENBOT_PUBLIC_URL (or BETTER_AUTH_URL) so GitHub can send people back to this server.", + ); + } const workerSharedSecret = optional(environment, "WORKER_SHARED_SECRET"); return { @@ -1311,9 +1321,7 @@ export function loadConfig( agentEndpointAllowedHosts: agentEndpointAllowedHosts(environment), deploymentId: optional(environment, "DEPLOYMENT_ID"), composioApiKey: optional(environment, "COMPOSIO_API_KEY"), - publicUrl: ( - optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl - )?.replace(/\/+$/, ""), + publicUrl, appUrl: ( optional(environment, "OPENBOT_APP_URL") ?? commaSeparated(environment, "TRUSTED_ORIGINS")[0] ?? diff --git a/server/tests/config-github-app.test.ts b/server/tests/config-github-app.test.ts index 5af173e0c..34d15f7d8 100644 --- a/server/tests/config-github-app.test.ts +++ b/server/tests/config-github-app.test.ts @@ -17,6 +17,19 @@ const baseEnvironment = { MANAGED_AGENT_TOKEN: "managed-agent-token", }; +/** Single-user mode has no sign-in, so nothing supplies BETTER_AUTH_URL and no public URL resolves. */ +function singleUserEnvironment() { + const { + BETTER_AUTH_URL: _url, + BETTER_AUTH_SECRET: _secret, + GOOGLE_OAUTH_CLIENT_ID: _id, + GOOGLE_OAUTH_CLIENT_SECRET: _clientSecret, + INITIAL_ADMIN_EMAILS: _admins, + ...rest + } = baseEnvironment; + return { ...rest, OPENBOT_SINGLE_USER: "true" }; +} + describe("GitHub App sign-in configuration", () => { test("is absent when no GITHUB_APP_* is set", () => { expect(loadConfig(baseEnvironment).githubApp).toBeUndefined(); @@ -56,4 +69,26 @@ describe("GitHub App sign-in configuration", () => { "GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET must be set together", ); }); + + test("refuses a GitHub App when no public URL resolves", () => { + expect(() => + loadConfig({ + ...singleUserEnvironment(), + GITHUB_APP_CLIENT_ID: "gh-id", + GITHUB_APP_CLIENT_SECRET: "gh-secret", + }), + ).toThrow( + "GITHUB_APP_CLIENT_ID needs OPENBOT_PUBLIC_URL (or BETTER_AUTH_URL) so GitHub can send people back to this server.", + ); + }); + + test("accepts a GitHub App when a public URL resolves", () => { + const config = loadConfig({ + ...baseEnvironment, + OPENBOT_PUBLIC_URL: "https://openbot.example.com", + GITHUB_APP_CLIENT_ID: "gh-id", + GITHUB_APP_CLIENT_SECRET: "gh-secret", + }); + expect(config.githubApp?.clientId).toBe("gh-id"); + }); }); From dc5c50d39f4521c8d1e8213c9e5da0a64d5a375d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:28:48 -0300 Subject: [PATCH 081/101] fix(app): only follow a GitHub sign-in address from github.com --- app/src/lib/identity/mutations.test.ts | 43 ++++++++++++++++++++++ app/src/lib/identity/mutations.ts | 49 +++++++++++++++++++------- 2 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 app/src/lib/identity/mutations.test.ts diff --git a/app/src/lib/identity/mutations.test.ts b/app/src/lib/identity/mutations.test.ts new file mode 100644 index 000000000..fe61f338c --- /dev/null +++ b/app/src/lib/identity/mutations.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { githubAuthorizationUrl } from "./mutations"; + +const FRIENDLY = "Could not start connecting GitHub. Try again."; +const VALID = + "https://github.com/login/oauth/authorize?client_id=abc&state=xyz"; + +describe("githubAuthorizationUrl", () => { + test("passes a github.com authorize URL through unchanged", () => { + expect(githubAuthorizationUrl({ authorizationUrl: VALID })).toBe(VALID); + }); + + test.each([ + ["a missing envelope", undefined], + ["null", null], + ["a string", VALID], + ["a missing field", {}], + ["a non-string field", { authorizationUrl: 42 }], + ["a javascript: URL", { authorizationUrl: "javascript:alert(1)" }], + [ + "an http URL", + { authorizationUrl: "http://github.com/login/oauth/authorize" }, + ], + [ + "another host", + { authorizationUrl: "https://evil.example/login/oauth/authorize" }, + ], + [ + "a lookalike host", + { + authorizationUrl: + "https://github.com.evil.example/login/oauth/authorize", + }, + ], + [ + "another path", + { authorizationUrl: "https://github.com/settings/tokens" }, + ], + ["an unparseable string", { authorizationUrl: "not a url" }], + ])("rejects %s with the friendly message", (_name, value) => { + expect(() => githubAuthorizationUrl(value)).toThrow(FRIENDLY); + }); +}); diff --git a/app/src/lib/identity/mutations.ts b/app/src/lib/identity/mutations.ts index a0fdeea36..b2086634f 100644 --- a/app/src/lib/identity/mutations.ts +++ b/app/src/lib/identity/mutations.ts @@ -58,22 +58,45 @@ export function issueSlackCodeMutationOptions() { }); } +const CONNECT_GITHUB_FAILED = "Could not start connecting GitHub. Try again."; + +/** + * The only address the browser will follow after asking to connect GitHub: the OAuth authorize page + * on github.com over https. Anything else — a missing envelope, `javascript:`, another host — is a + * broken or tampered response, and navigating to it would hand the person to somewhere else. + */ +export function githubAuthorizationUrl(value: unknown): string { + const candidate = + value && typeof value === "object" + ? (value as { authorizationUrl?: unknown }).authorizationUrl + : undefined; + if (typeof candidate !== "string") throw new Error(CONNECT_GITHUB_FAILED); + let url: URL; + try { + url = new URL(candidate); + } catch { + throw new Error(CONNECT_GITHUB_FAILED); + } + if ( + url.protocol !== "https:" || + url.host !== "github.com" || + url.pathname !== "/login/oauth/authorize" + ) { + throw new Error(CONNECT_GITHUB_FAILED); + } + return candidate; +} + /** Resolves to the GitHub consent URL; the caller navigates, not this factory. */ export function connectGithubMutationOptions() { return mutationOptions({ - mutationFn: async (): Promise => { - const { authorizationUrl } = await client<{ authorizationUrl: unknown }>( - "/api/identity/github/connect", - "connect", - { + mutationFn: async (): Promise => + // `client` yields undefined when the envelope key is absent; the validator handles that. + githubAuthorizationUrl( + await client("/api/identity/github/connect", "connect", { method: "POST", - fallback: "Could not start connecting GitHub", - }, - ); - if (typeof authorizationUrl !== "string") { - throw new Error("Could not start connecting GitHub"); - } - return authorizationUrl; - }, + fallback: CONNECT_GITHUB_FAILED, + }), + ), }); } From 7380b12ea8337dd85e0c8e21a4a4b86cad18eb1f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:29:08 -0300 Subject: [PATCH 082/101] fix(app): say when the linkable account types could not be checked --- .../settings/linked-accounts.test.tsx | 55 ++++++++++++++++++- .../components/settings/linked-accounts.tsx | 25 +++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index c886f343f..c52eb63e6 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -146,6 +146,7 @@ test("each account type that can be linked gets its own card to connect from", ( { kind: "available", provider: "github", title: "GitHub" }, ], error: null, + providersError: null, }); expect( linkedAccountsSectionState({ @@ -156,6 +157,7 @@ test("each account type that can be linked gets its own card to connect from", ( ).toEqual({ entries: [{ kind: "available", provider: "github", title: "GitHub" }], error: null, + providersError: null, }); }); @@ -172,6 +174,7 @@ test("a linked account takes the place of its connect card", () => { { kind: "linked", account: row }, ], error: null, + providersError: null, }); }); @@ -188,6 +191,7 @@ test("links stay listed, in Slack then GitHub order, even when linking is off", { kind: "linked", account: row }, ], error: null, + providersError: null, }); }); @@ -198,7 +202,11 @@ test("a failed refetch keeps stale rows on screen and shows the error with them" error: new Error("boom"), providers: none, }), - ).toEqual({ entries: [{ kind: "linked", account: row }], error: "boom" }); + ).toEqual({ + entries: [{ kind: "linked", account: row }], + error: "boom", + providersError: null, + }); }); test("an error with no data, or with an empty cached list, still shows", () => { @@ -209,10 +217,53 @@ test("an error with no data, or with an empty cached list, still shows", () => { error: new Error("boom"), providers: none, }), - ).toEqual({ entries: [], error: "boom" }); + ).toEqual({ entries: [], error: "boom", providersError: null }); + } +}); + +test("a providers failure keeps the section on screen even with no links", () => { + for (const data of [undefined, []]) { + expect( + linkedAccountsSectionState({ + data, + error: null, + providers: undefined, + providersError: new Error("providers down"), + }), + ).toEqual({ entries: [], error: null, providersError: "providers down" }); } }); +test("a providers failure keeps existing links and reports beside them", () => { + expect( + linkedAccountsSectionState({ + data: [row], + error: null, + providers: undefined, + providersError: new Error("providers down"), + }), + ).toEqual({ + entries: [{ kind: "linked", account: row }], + error: null, + providersError: "providers down", + }); +}); + +test("no providers failure leaves the providers error empty", () => { + expect( + linkedAccountsSectionState({ + data: [row], + error: null, + providers: none, + providersError: null, + }), + ).toEqual({ + entries: [{ kind: "linked", account: row }], + error: null, + providersError: null, + }); +}); + test("a 204 disconnect succeeds", async () => { await expect( unlinkOutcome(new Response(null, { status: 204 })), diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index eff0d307a..e7196d5b0 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -79,17 +79,24 @@ export type LinkedAccountsEntry = * can no longer be linked, so they can still be disconnected; links of a type this build does not * know come last. Connect cards need the list: with no list loaded there is no knowing whether the * account is already linked. Hidden when there is nothing to list, nothing to connect and nothing - * to report. A failed refetch keeps the stale entries on screen with the error under them. + * to report. A failed refetch keeps the stale entries on screen with the error under them. A failed + * providers check is reported too, so a missing Connect card is never unexplained. */ export function linkedAccountsSectionState({ data, error, providers, + providersError = null, }: { data: LinkedAccount[] | undefined; error: Error | null; providers?: IdentityProviders; -}): { entries: LinkedAccountsEntry[]; error: string | null } | null { + providersError?: Error | null; +}): { + entries: LinkedAccountsEntry[]; + error: string | null; + providersError: string | null; +} | null { const links = data ?? []; const entries: LinkedAccountsEntry[] = []; for (const { provider, title } of linkableProviders) { @@ -105,8 +112,12 @@ export function linkedAccountsSectionState({ ) entries.push({ kind: "linked", account }); } - if (entries.length === 0 && !error) return null; - return { entries, error: error ? error.message : null }; + if (entries.length === 0 && !error && !providersError) return null; + return { + entries, + error: error ? error.message : null, + providersError: providersError ? providersError.message : null, + }; } /** @@ -134,6 +145,7 @@ export function LinkedAccountsSection() { data: links.data, error: links.error, providers: providers.data, + providersError: providers.error, }); if (!state) return null; const connect = { @@ -178,6 +190,11 @@ export function LinkedAccountsSection() { Your linked accounts could not be loaded: {state.error}

) : null} + {state.providersError ? ( +

+ Could not check which accounts can be linked: {state.providersError} +

+ ) : null} {issueCode.error ? (

Could not create a Slack link code: {issueCode.error.message} From a6d88f17421a7da814cd1d341f2963e32dd07bc5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:29:26 -0300 Subject: [PATCH 083/101] fix(server): keep the existing GitHub link intact when a re-link fails On a reconnect the callback rotated the live github-user-token credential up front (committing the old token's revocation) and only then called linkVerified. If the link write threw, the catch revoked the new token too, leaving the person's link active but pointing at a revoked token while the page said nothing was saved. The callback no longer rotates. Each connection stores its token with create() under its own key, `${userId}:${githubId}:`, so it can never collide with the live token (credentials_active_key_idx) and the old one is not touched before the link is written. linkVerified -> writeLink already revokes the credential the link previously named, inside the same transaction that repoints the link (revokeQuietly on existing.credentialId for a relink, on previous.credentialId when a different account replaces the realm's link). One live token per person+account still holds; on any failure the callback revokes only the token this attempt stored. Readers of keyId / findLiveByKey checked: - identity/store.ts claimCredential: selects the link's credential by id, kind "connector" and provider "github-user-token"; never by keyId. - plugins/store.ts (two findLiveByKey calls): MCP user-connection and OAuth-client keys, other providers. - credentials.ts addCredential service: admin-entered keys. No code looks a github-user-token up by keyId; the link holds it by id. The callback's credential deps narrow to create and revoke. --- server/src/identity/github-callback.ts | 30 +++--- ...entity-github-callback.integration.test.ts | 94 +++++++++++++++---- server/tests/identity-github-callback.test.ts | 43 +++++---- 3 files changed, 112 insertions(+), 55 deletions(-) diff --git a/server/src/identity/github-callback.ts b/server/src/identity/github-callback.ts index a1b156679..68edb5a3d 100644 --- a/server/src/identity/github-callback.ts +++ b/server/src/identity/github-callback.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { Hono } from "hono"; import { type AuditStore, recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; @@ -18,10 +19,7 @@ export type GithubCallbackDeps = { appUrl: string | undefined; encryptionKey: string; personIsActive(userId: string): Promise; - credentials: Pick< - CredentialStore, - "create" | "rotate" | "revoke" | "findLiveByKey" - >; + credentials: Pick; identity: Pick; auditStore?: AuditStore; fetchImpl?: typeof fetch; @@ -73,26 +71,21 @@ export function githubCallbackRoutes( ); const user = await fetchGithubUser(tokens.accessToken, deps.fetchImpl); - const key = { - kind: "connector" as const, + // Every connection stores its token under a key of its own, so it never collides with the + // token a reconnect replaces (one live credential per key). Nothing reads this token by key: + // the link names it by id, and linkVerified revokes the token the link named before in the + // same transaction that repoints it. That keeps one live token per person and account, and + // a link write that fails leaves the existing link and its token exactly as they were. + const stored = await deps.credentials.create({ + kind: "connector", provider: "github-user-token", - keyId: `${state.userId}:${user.id}`, - }; - const value = { - ...key, + keyId: `${state.userId}:${user.id}:${randomUUID()}`, metadata: { login: user.login }, encryptedValue: await encryptSecret( deps.encryptionKey, JSON.stringify(tokens), ), - }; - const live = await deps.credentials.findLiveByKey(key); - const stored = live - ? await deps.credentials.rotate({ - ...value, - previousCredentialId: live.id, - }) - : await deps.credentials.create(value); + }); let link: Awaited>; try { @@ -106,6 +99,7 @@ export function githubCallbackRoutes( { method: "oauth", handle: user.login, credentialId: stored.id }, ); } catch (error) { + // Only the token this attempt stored; the existing link's token was never touched. try { await deps.credentials.revoke(stored.id); } catch { diff --git a/server/tests/identity-github-callback.integration.test.ts b/server/tests/identity-github-callback.integration.test.ts index 2884672c8..261b5c157 100644 --- a/server/tests/identity-github-callback.integration.test.ts +++ b/server/tests/identity-github-callback.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, expect, test } from "bun:test"; import { randomInt, randomUUID } from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNull, like, or } from "drizzle-orm"; import { Hono } from "hono"; import type { AppVariables } from "../src/auth/guards"; import { createCredentialStore, decryptSecret } from "../src/credentials"; @@ -19,17 +19,24 @@ const database = createDatabase(testDatabaseUrl(), TEST_POOL); const vault = createCredentialStore(database); const identity = createIdentityStore(database, vault); const usedSubjects: string[] = []; -const usedKeyIds: string[] = []; +// Each connection stores its token under `${userId}:${githubId}:`; tests track the prefix. +const usedKeyPrefixes: string[] = []; afterAll(async () => { if (usedSubjects.length) await database .delete(identityLinks) .where(inArray(identityLinks.subject, usedSubjects)); - if (usedKeyIds.length) + if (usedKeyPrefixes.length) await database .delete(credentials) - .where(inArray(credentials.keyId, usedKeyIds)); + .where( + or( + ...usedKeyPrefixes.map((prefix) => + like(credentials.keyId, `${prefix}%`), + ), + ), + ); await database.$client.close(); }); @@ -41,7 +48,11 @@ function githubAccount() { return { id, login: `login-${id}` }; } -function harness(account: { id: number; login: string }, token: string) { +function harness( + account: { id: number; login: string }, + token: string, + links: Pick = identity, +) { const fetchCalls: string[] = []; const fetchImpl = (async (url: string | URL | Request) => { fetchCalls.push(String(url)); @@ -64,7 +75,7 @@ function harness(account: { id: number; login: string }, token: string) { encryptionKey, personIsActive: async () => true, credentials: vault, - identity, + identity: links, fetchImpl, }); @@ -86,11 +97,17 @@ function harness(account: { id: number; login: string }, token: string) { } const keyId = (userId: string, account: { id: number }) => { - const value = `${userId}:${account.id}`; - usedKeyIds.push(value); + const value = `${userId}:${account.id}:`; + usedKeyPrefixes.push(value); return value; }; +const credentialsUnder = (prefix: string) => + database + .select() + .from(credentials) + .where(like(credentials.keyId, `${prefix}%`)); + const linkFor = async (account: { id: number }) => { const [row] = await database .select() @@ -144,10 +161,10 @@ test("a first connection links the account and stores a live token", async () => expect(tokens.accessToken).toBe("gho_first"); }); -test("connecting again rotates the credential and revokes the previous one", async () => { +test("connecting again replaces the credential and revokes the previous one", async () => { const account = githubAccount(); const user = person(); - keyId(user, account); + const prefix = keyId(user, account); const first = await harness(account, "gho_one").run(user, user); expect(first.headers.get("location")).toBe(OK); @@ -166,6 +183,48 @@ test("connecting again rotates the credential and revokes the previous one", asy ); expect(tokens.accessToken).toBe("gho_two"); expect((await credentialById(firstCredentialId))?.revokedAt).not.toBeNull(); + const live = await database + .select({ id: credentials.id }) + .from(credentials) + .where( + and(like(credentials.keyId, `${prefix}%`), isNull(credentials.revokedAt)), + ); + expect(live).toEqual([{ id: link?.credentialId as string }]); +}); + +test("a reconnect whose link write fails leaves the existing link and its token as they were", async () => { + const account = githubAccount(); + const user = person(); + const prefix = keyId(user, account); + + const first = await harness(account, "gho_kept").run(user, user); + expect(first.headers.get("location")).toBe(OK); + const before = await linkFor(account); + + const failing = harness(account, "gho_lost", { + linkVerified: async () => { + throw new Error("database unreachable"); + }, + }); + const response = await failing.run(user, user); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(FAILED); + const after = await linkFor(account); + expect(after).toMatchObject({ + status: "active", + credentialId: before?.credentialId, + }); + const kept = await credentialById(before?.credentialId); + expect(kept?.revokedAt).toBeNull(); + const tokens = JSON.parse( + await decryptSecret(encryptionKey, kept?.encryptedValue ?? ""), + ); + expect(tokens.accessToken).toBe("gho_kept"); + const live = (await credentialsUnder(prefix)).filter( + (row) => row.revokedAt === null, + ); + expect(live.map((row) => row.id)).toEqual([before?.credentialId as string]); }); test("an account already linked to someone else fails and revokes the new token", async () => { @@ -191,11 +250,9 @@ test("an account already linked to someone else fails and revokes the new token" userId: owner, credentialId: before?.credentialId, }); - const [stranded] = await database - .select() - .from(credentials) - .where(eq(credentials.keyId, intruderKey)); - expect(stranded?.revokedAt).not.toBeNull(); + const stranded = await credentialsUnder(intruderKey); + expect(stranded).toHaveLength(1); + expect(stranded[0]?.revokedAt).not.toBeNull(); }); test("a state sealed for another person fails before GitHub is called", async () => { @@ -212,9 +269,6 @@ test("a state sealed for another person fails before GitHub is called", async () expect(response.headers.get("location")).toBe(FAILED); expect(fetchCalls).toEqual([]); expect(await linkFor(account)).toBeUndefined(); - const stored = await database - .select() - .from(credentials) - .where(inArray(credentials.keyId, [starterKey, sessionKey])); - expect(stored).toEqual([]); + expect(await credentialsUnder(starterKey)).toEqual([]); + expect(await credentialsUnder(sessionKey)).toEqual([]); }); diff --git a/server/tests/identity-github-callback.test.ts b/server/tests/identity-github-callback.test.ts index f401a8112..69f8e40c1 100644 --- a/server/tests/identity-github-callback.test.ts +++ b/server/tests/identity-github-callback.test.ts @@ -20,7 +20,6 @@ function build( options: { actorId?: string | null; active?: boolean; - live?: { id: string } | null; tokenFails?: boolean; linkError?: Error; revokeError?: Error; @@ -29,7 +28,6 @@ function build( ) { const fetchCalls: string[] = []; const created: unknown[] = []; - const rotated: unknown[] = []; const revoked: string[] = []; const linked: unknown[][] = []; const audits: AuditEventInput[] = []; @@ -64,16 +62,11 @@ function build( created.push(value); return { id: "cred-new", revokedAt: null }; }, - rotate: async (value) => { - rotated.push(value); - return { id: "cred-new", revokedAt: null }; - }, revoke: async (id) => { revoked.push(id); if (options.revokeError) throw options.revokeError; return new Date(); }, - findLiveByKey: async () => options.live ?? null, }, identity: { linkVerified: async (...args: unknown[]) => { @@ -101,7 +94,6 @@ function build( app, fetchCalls, created, - rotated, revoked, linked, audits, @@ -159,9 +151,9 @@ describe("GitHub sign-in callback", () => { expect(stored).toMatchObject({ kind: "connector", provider: "github-user-token", - keyId: "person:42", metadata: { login: "dana" }, }); + expect(stored.keyId).toMatch(/^person:42:[0-9a-f-]{36}$/); expect(JSON.parse(await decryptSecret(key, stored.encryptedValue))).toEqual( { accessToken: "gho_secret", @@ -191,17 +183,35 @@ describe("GitHub sign-in callback", () => { ]); }); - test("a reconnect rotates the live credential instead of inserting", async () => { - const harness = build({ live: { id: "cred-old" } }); + test("a reconnect stores a new credential under its own key and rotates nothing", async () => { + // Retiring the old credential is the link write's job, in the same transaction that repoints + // the link, so a reconnect never touches the live credential up front. + const harness = build(); expect( await call(harness, `code=the-code&state=${await validState()}`), ).toBe(OK); - expect(harness.created).toHaveLength(0); - expect(harness.rotated).toHaveLength(1); - expect(harness.rotated[0]).toMatchObject({ - previousCredentialId: "cred-old", - keyId: "person:42", + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(OK); + expect(harness.revoked).toHaveLength(0); + expect(harness.created).toHaveLength(2); + const [first, second] = harness.created as { keyId: string }[]; + expect(first?.keyId).toMatch(/^person:42:[0-9a-f-]{36}$/); + expect(second?.keyId).toMatch(/^person:42:[0-9a-f-]{36}$/); + expect(first?.keyId).not.toBe(second?.keyId); + }); + + test("a re-link whose link write throws leaves the old credential live and unrevoked", async () => { + const harness = build({ + linkError: new Error("database unreachable"), }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + // The callback holds no handle on the old credential at all (no rotate, no lookup by key): + // the only revoke it issues is for the token this attempt stored. + expect(harness.revoked).toEqual(["cred-new"]); + expect(harness.audits).toHaveLength(0); }); test("a denied authorization fails without calling GitHub", async () => { @@ -274,7 +284,6 @@ describe("GitHub sign-in callback", () => { await call(harness, `code=the-code&state=${await validState()}`), ).toBe(FAILED); expect(harness.created).toHaveLength(0); - expect(harness.rotated).toHaveLength(0); expect(harness.linked).toHaveLength(0); }); From 9d68b17524667c61f637c3d33522fcc94052affa Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:30:25 -0300 Subject: [PATCH 084/101] fix(server): drop the unused GITHUB_APP_SLUG setting Nothing read config.githubApp.slug. Removed: GithubAppConfig.slug and its parser (server/src/config.ts), the GITHUB_APP_SLUG lines in .env.example, the slug mention in docs/configuration.md, and the slug test case (now asserts the setting is ignored). --- .env.example | 4 +--- docs/configuration.md | 4 ++-- server/src/config.ts | 6 +----- server/tests/config-github-app.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index c70ef2afb..8ea242ac4 100644 --- a/.env.example +++ b/.env.example @@ -443,11 +443,9 @@ COMPOSIO_API_KEY= # A GitHub App lets people link their GitHub account under Settings -> Connected accounts -> Linked # accounts. Register the App's callback URL as /api/identity/github/callback and keep # "Expire user authorization tokens" on. A webhook is not needed for linking. Set both the client id -# and the client secret, or neither: one without the other stops the server at start-up. The slug is -# optional and is used for links to the App. +# and the client secret, or neither: one without the other stops the server at start-up. # GITHUB_APP_CLIENT_ID= # GITHUB_APP_CLIENT_SECRET= -# GITHUB_APP_SLUG= # --------------------------------------------------------------------------- # Reaching people outside the app: Slack, Teams, SMS, push diff --git a/docs/configuration.md b/docs/configuration.md index 7ea1aed06..047e68354 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -521,8 +521,8 @@ GitHub uses a GitHub App. Create one under GitHub Settings, Developer settings, - "Expire user authorization tokens" on. A webhook is not required for linking. - A client secret, generated on the App's page. -Then set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`; set both or neither. `GITHUB_APP_SLUG` -is optional. People then use **Connect GitHub** while signed in to OpenBot in the same browser. The +Then set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`; set both or neither. People +then use **Connect GitHub** while signed in to OpenBot in the same browser. The GitHub user token is stored encrypted with the link. Reachability codes and Linked accounts codes look alike, and Reachability's are checked first. A diff --git a/server/src/config.ts b/server/src/config.ts index 1e20ee640..2549bd953 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -93,7 +93,6 @@ export type OAuthClient = { clientId: string; clientSecret: string }; export type GithubAppConfig = { clientId: string; clientSecret: string; - slug?: string; }; export type AuthConfig = { @@ -677,7 +676,6 @@ function githubAppConfig( ): GithubAppConfig | undefined { const clientId = optional(environment, "GITHUB_APP_CLIENT_ID"); const clientSecret = optional(environment, "GITHUB_APP_CLIENT_SECRET"); - const slug = optional(environment, "GITHUB_APP_SLUG"); // Both or neither. One alone is a half-configured sign-in that fails at the first attempt rather // than at start-up, which is the worst moment to discover it. @@ -687,9 +685,7 @@ function githubAppConfig( ); } - return clientId && clientSecret - ? { clientId, clientSecret, ...(slug ? { slug } : {}) } - : undefined; + return clientId && clientSecret ? { clientId, clientSecret } : undefined; } function commaSeparated(environment: Environment, name: string): string[] { diff --git a/server/tests/config-github-app.test.ts b/server/tests/config-github-app.test.ts index 34d15f7d8..a7b810bd6 100644 --- a/server/tests/config-github-app.test.ts +++ b/server/tests/config-github-app.test.ts @@ -47,7 +47,7 @@ describe("GitHub App sign-in configuration", () => { }); }); - test("includes the slug when set", () => { + test("ignores GITHUB_APP_SLUG: the config carries only the pair", () => { const config = loadConfig({ ...baseEnvironment, GITHUB_APP_CLIENT_ID: "gh-id", @@ -57,8 +57,8 @@ describe("GitHub App sign-in configuration", () => { expect(config.githubApp).toEqual({ clientId: "gh-id", clientSecret: "gh-secret", - slug: "openbot", }); + expect(config.githubApp).not.toHaveProperty("slug"); }); test.each([ From 6d948bc63a8b481c22baa7baf5d2fdc6f5984d18 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:30:28 -0300 Subject: [PATCH 085/101] fix(server): report a committed link as linked even if its audit write fails --- server/src/identity/github-callback.ts | 14 +++++++- server/src/identity/slack-redeem.ts | 14 ++++++-- server/tests/identity-github-callback.test.ts | 32 ++++++++++++++++++- server/tests/identity-slack-redeem.test.ts | 30 ++++++++++++++++- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/server/src/identity/github-callback.ts b/server/src/identity/github-callback.ts index 68edb5a3d..562ce4a18 100644 --- a/server/src/identity/github-callback.ts +++ b/server/src/identity/github-callback.ts @@ -26,6 +26,16 @@ export type GithubCallbackDeps = { now?: () => Date; }; +function logLinkAuditFailure(provider: string, error: unknown): void { + console.error( + JSON.stringify({ + type: "identity-link-audit-failed", + provider, + error: String(error), + }), + ); +} + /** * Finishes linking a GitHub account when GitHub sends the person back. * @@ -108,6 +118,8 @@ export function githubCallbackRoutes( throw error; } + // The link and its token are live from here on. A failed audit write is logged and does not + // turn the redirect into `?linked=failed`, whose notice says nothing was saved. if (deps.auditStore) { await recordAuditEvent(deps.auditStore, { eventType: "identity.linked", @@ -115,7 +127,7 @@ export function githubCallbackRoutes( targetId: link.id, actorUserId: state.userId, payload: { actor: state.userId, provider: "github" }, - }); + }).catch((error) => logLinkAuditFailure("github", error)); } return context.redirect(`${base}github`, 302); } catch (error) { diff --git a/server/src/identity/slack-redeem.ts b/server/src/identity/slack-redeem.ts index 37b156235..9b85e2984 100644 --- a/server/src/identity/slack-redeem.ts +++ b/server/src/identity/slack-redeem.ts @@ -9,8 +9,8 @@ import { /** * Redeems a link code a person sent from Slack. Failures a person can cause become a result; any - * other error propagates. The audit event is written after the link commits and is not caught, as - * for unlinking: it names the link and provider, never the realm or Slack subject. + * other error propagates. The audit event is written after the link commits; a failed write is + * logged and the person is still told they are linked. It names the link and provider, never the realm or Slack subject. */ export function slackCodeRedeemer( store: Pick, @@ -51,7 +51,15 @@ export function slackCodeRedeemer( targetId: link.id, actorUserId: link.userId, payload: { actor: link.userId, provider: "slack" }, - }); + }).catch((error) => + console.error( + JSON.stringify({ + type: "identity-link-audit-failed", + provider: "slack", + error: String(error), + }), + ), + ); } return "linked"; }, diff --git a/server/tests/identity-github-callback.test.ts b/server/tests/identity-github-callback.test.ts index 69f8e40c1..6fa500e97 100644 --- a/server/tests/identity-github-callback.test.ts +++ b/server/tests/identity-github-callback.test.ts @@ -23,6 +23,7 @@ function build( tokenFails?: boolean; linkError?: Error; revokeError?: Error; + auditError?: Error; nowAt?: Date; } = {}, ) { @@ -75,7 +76,12 @@ function build( return { id: "link-1", userId: "person" } as never; }, }, - auditStore: { insert: async (event) => void audits.push(event) }, + auditStore: { + insert: async (event) => { + if (options.auditError) throw options.auditError; + audits.push(event); + }, + }, fetchImpl, now: () => options.nowAt ?? now, }); @@ -287,6 +293,30 @@ describe("GitHub sign-in callback", () => { expect(harness.linked).toHaveLength(0); }); + test("an audit write that fails after the link committed still reports linked, and logs", async () => { + const harness = build({ auditError: new Error("audit db down") }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(OK); + expect(harness.linked).toHaveLength(1); + expect(harness.revoked).toHaveLength(0); + const logged = errors.mock.calls.map((args: unknown[]) => String(args[0])); + expect(logged).toHaveLength(1); + expect(JSON.parse(logged[0] as string)).toEqual({ + type: "identity-link-audit-failed", + provider: "github", + error: "Error: audit db down", + }); + for (const secret of [ + "the-code", + "gho_secret", + "ghr_secret", + "client-secret", + clientSecret, + ]) + expect(output()).not.toContain(secret); + }); + test("an identity conflict revokes the new credential and does not audit", async () => { const harness = build({ linkError: new IdentityConflictError(), diff --git a/server/tests/identity-slack-redeem.test.ts b/server/tests/identity-slack-redeem.test.ts index b5e790ee5..5468762d5 100644 --- a/server/tests/identity-slack-redeem.test.ts +++ b/server/tests/identity-slack-redeem.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import type { AuditEventInput, AuditStore } from "../src/audit"; import { slackCodeRedeemer } from "../src/identity/slack-redeem"; import type { IdentityStore } from "../src/identity/store"; @@ -112,6 +112,34 @@ describe("slackCodeRedeemer", () => { ).rejects.toThrow("db down"); }); + test("an audit write that fails after the link committed still reports linked, and logs", async () => { + const { store } = setup(async () => link); + const errors = spyOn(console, "error").mockImplementation(() => {}); + try { + const failing: AuditStore = { + insert: async () => { + throw new Error("audit db down"); + }, + }; + expect( + await slackCodeRedeemer(store, failing).redeem("CODE-1", sender), + ).toBe("linked"); + const logged = errors.mock.calls.map((args: unknown[]) => + String(args[0]), + ); + expect(logged).toHaveLength(1); + expect(JSON.parse(logged[0] as string)).toEqual({ + type: "identity-link-audit-failed", + provider: "slack", + error: "Error: audit db down", + }); + expect(logged[0]).not.toContain("CODE-1"); + expect(logged[0]).not.toContain("U1"); + } finally { + errors.mockRestore(); + } + }); + test("works without an audit store", async () => { const { store } = setup(async () => link); expect(await slackCodeRedeemer(store).redeem("X", sender)).toBe("linked"); From 5c2e7307c677630232cbbc5221397271594b821a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:30:42 -0300 Subject: [PATCH 086/101] fix(app): say when the Slack link code could not be copied --- .../settings/linked-accounts.test.tsx | 25 ++++++++++++++++ .../components/settings/linked-accounts.tsx | 30 +++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index c52eb63e6..7c917918a 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -3,6 +3,7 @@ import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { unlinkOutcome } from "@/lib/identity/mutations"; import { linksFromResponse } from "@/lib/identity/queries"; import { + copyText, linkedAccountDescription, linkedAccountsSectionState, providerIcon, @@ -309,3 +310,27 @@ test("a disconnect failure without a message throws the fallback", async () => { unlinkOutcome(new Response("nope", { status: 502 })), ).rejects.toThrow("The account could not be disconnected"); }); + +test("copying succeeds when the clipboard accepts the text", async () => { + const written: string[] = []; + const ok = await copyText("link abc", { + writeText: async (text: string) => { + written.push(text); + }, + }); + expect(ok).toBe(true); + expect(written).toEqual(["link abc"]); +}); + +test("copying reports failure when the clipboard API is missing", async () => { + expect(await copyText("link abc", undefined)).toBe(false); +}); + +test("copying reports failure when the write is rejected", async () => { + const ok = await copyText("link abc", { + writeText: async () => { + throw new Error("denied"); + }, + }); + expect(ok).toBe(false); +}); diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index e7196d5b0..743163870 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -133,9 +133,11 @@ export function LinkedAccountsSection() { const connectGithub = useMutation(connectGithubMutationOptions()); const [issued, setIssued] = useState(null); const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); const closeDialog = () => { setIssued(null); setCopied(false); + setCopyFailed(false); // A link may have completed while the dialog was open. queryClient.invalidateQueries({ queryKey: identityKeys.links() }); }; @@ -227,15 +229,22 @@ export function LinkedAccountsSection() { size="sm" variant="outline" onClick={async () => { - await navigator.clipboard.writeText( + const ok = await copyText( `link ${issued.code}`, + navigator.clipboard, ); - setCopied(true); + setCopied(ok); + setCopyFailed(!ok); }} > {copied ? "Copied" : "Copy"}

+ {copyFailed ? ( +

+ Couldn't copy. Select the code and copy it yourself. +

+ ) : null}

The code works once and expires in 10 minutes.

@@ -327,3 +336,20 @@ function LinkedAccountRow({ account }: { account: LinkedAccount }) { ); } + +/** + * Write text to the clipboard, answering whether it landed. The clipboard is missing on plain-http + * hosts and a write is refused when permission is denied; both are a "no", not an exception. + */ +export async function copyText( + text: string, + clipboard: Pick | undefined, +): Promise { + if (!clipboard) return false; + try { + await clipboard.writeText(text); + return true; + } catch { + return false; + } +} From 1ac677fae741bf4ba77d9c6d34c2e32e8c9da7b0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:30:42 -0300 Subject: [PATCH 087/101] test(server): prove the GitHub callback works through createApp and clean up precisely --- server/tests/identity-app-wiring.test.ts | 71 +++++++++++++++++++ ...entity-github-callback.integration.test.ts | 9 ++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/server/tests/identity-app-wiring.test.ts b/server/tests/identity-app-wiring.test.ts index 40912ea38..29cc10029 100644 --- a/server/tests/identity-app-wiring.test.ts +++ b/server/tests/identity-app-wiring.test.ts @@ -192,3 +192,74 @@ test("the GitHub callback is reachable with a session and redirects to the app", ); expect(fetchImpl).not.toHaveBeenCalled(); }); + +/** A real callback router whose GitHub is a stub that answers the token exchange and GET /user. */ +function workingCallbackFor(environment: Record) { + const fetchImpl = mock(async (url: string | URL | Request) => + String(url).includes("access_token") + ? Response.json({ + access_token: "gho_wired", + refresh_token: "gho_wired-refresh", + expires_in: 28800, + refresh_token_expires_in: 15897600, + }) + : Response.json({ id: 4242, login: "octo" }), + ); + const linkVerified = mock(async () => ({ id: "link-1" })); + const audited: unknown[] = []; + const encryptionKey = loadConfig( + testEnvironment(environment), + ).keyEncryptionKey; + const router = githubCallbackRoutes({ + clientId: "Iv1.x", + clientSecret: "s", + publicUrl: "https://api.test", + appUrl: "https://app.test", + encryptionKey, + personIsActive: async () => true, + credentials: { + create: async () => ({ id: "credential-1" }), + revoke: async () => undefined, + } as never, + identity: { linkVerified } as never, + auditStore: { insert: async (event: unknown) => void audited.push(event) }, + fetchImpl: fetchImpl as never, + }); + return { router, fetchImpl, linkVerified, audited, encryptionKey }; +} + +test("the signed-in person's session reaches the callback and completes the link", async () => { + const { router, fetchImpl, linkVerified, audited, encryptionKey } = + workingCallbackFor(GITHUB_ENVIRONMENT); + const state = await sealGithubState("person", encryptionKey); + const response = await appFor(GITHUB_ENVIRONMENT, { + githubCallback: router, + }).request( + `http://openbot.test/api/identity/github/callback?code=c&state=${encodeURIComponent(state)}`, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://app.test/settings/connected-accounts?linked=github", + ); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(linkVerified).toHaveBeenCalledTimes(1); + expect((linkVerified.mock.calls[0] as unknown[])[1]).toBe("person"); + expect(audited).toHaveLength(1); +}); + +test("a session for a different person than the state's fails and never reaches GitHub", async () => { + const { router, fetchImpl, linkVerified, encryptionKey } = + workingCallbackFor(GITHUB_ENVIRONMENT); + const state = await sealGithubState("someone-else", encryptionKey); + const response = await appFor(GITHUB_ENVIRONMENT, { + githubCallback: router, + }).request( + `http://openbot.test/api/identity/github/callback?code=c&state=${encodeURIComponent(state)}`, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://app.test/settings/connected-accounts?linked=failed", + ); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(linkVerified).not.toHaveBeenCalled(); +}); diff --git a/server/tests/identity-github-callback.integration.test.ts b/server/tests/identity-github-callback.integration.test.ts index 261b5c157..5ce1ff671 100644 --- a/server/tests/identity-github-callback.integration.test.ts +++ b/server/tests/identity-github-callback.integration.test.ts @@ -26,7 +26,13 @@ afterAll(async () => { if (usedSubjects.length) await database .delete(identityLinks) - .where(inArray(identityLinks.subject, usedSubjects)); + .where( + and( + eq(identityLinks.provider, "github"), + eq(identityLinks.realm, "github.com"), + inArray(identityLinks.subject, usedSubjects), + ), + ); if (usedKeyPrefixes.length) await database .delete(credentials) @@ -250,6 +256,7 @@ test("an account already linked to someone else fails and revokes the new token" userId: owner, credentialId: before?.credentialId, }); + expect((await credentialById(before?.credentialId))?.revokedAt).toBeNull(); const stranded = await credentialsUnder(intruderKey); expect(stranded).toHaveLength(1); expect(stranded[0]?.revokedAt).not.toBeNull(); From e6f95249afebe30e460458e0d57e7d6493f9a86d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:31:40 -0300 Subject: [PATCH 088/101] docs: describe linking accounts as it now works --- .env.example | 16 +++++++++------ docs/configuration.md | 45 ++++++++++++++++++++++++++----------------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index 8ea242ac4..465c3bcb1 100644 --- a/.env.example +++ b/.env.example @@ -98,11 +98,12 @@ OPENBOT_SINGLE_USER=true # SIGNIN_ALLOWED_EMAIL_DOMAINS=example.com,example.co.uk # Where this deployment is reached from outside. Only needed for connectors that a person connects -# their own account to, such as Google Drive: it builds the redirect URI the vendor sends them back -# to, which has to match what an administrator registered character for character. Defaults to -# BETTER_AUTH_URL, so most deployments never set it; set it where the API is behind a different -# public address than the one sign-in uses. Without either, the Plugins page says so and nobody can -# connect an account. +# their own account to, such as Google Drive, and for linking GitHub accounts: it builds the +# redirect URI the vendor sends them back to, which has to match what an administrator registered +# character for character. Defaults to BETTER_AUTH_URL, so most deployments never set it; set it +# where the API is behind a different public address than the one sign-in uses. Without either, the +# Plugins page says so and nobody can connect an account, and GITHUB_APP_CLIENT_ID stops the server +# at start-up. # OPENBOT_PUBLIC_URL=https://openbot.example.com # Where the browser app is served from. The OAuth callback lands on the API and has to send the @@ -443,7 +444,10 @@ COMPOSIO_API_KEY= # A GitHub App lets people link their GitHub account under Settings -> Connected accounts -> Linked # accounts. Register the App's callback URL as /api/identity/github/callback and keep # "Expire user authorization tokens" on. A webhook is not needed for linking. Set both the client id -# and the client secret, or neither: one without the other stops the server at start-up. +# and the client secret, or neither: one without the other stops the server at start-up, and so does +# setting them with no public URL (OPENBOT_PUBLIC_URL or BETTER_AUTH_URL). People must be signed in +# to OpenBot in the same browser to connect. Afterwards the browser returns to OPENBOT_APP_URL, which +# you need when the app and the server are on different origins. # GITHUB_APP_CLIENT_ID= # GITHUB_APP_CLIENT_SECRET= diff --git a/docs/configuration.md b/docs/configuration.md index 047e68354..b0c724c53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -489,18 +489,20 @@ number opted out, later messages show `opted_out` in the delivery history instea ## Linked accounts OpenBot keeps a record of a person's accounts at outside providers, such as their GitHub account -or their Slack user. Whenever the deployment has an identity store, the section appears under -**Linked accounts** on `/settings/connected-accounts`. It lists the person's links and offers only -the actions this deployment can complete, and each link can be disconnected there. Disconnecting -revokes any token stored with the link, and is audited. - -See "Linking Slack and GitHub accounts" below for setting up each provider. An integration proves an account in one of two ways: by the provider's own -sign-in, or by a one-time code that OpenBot issues to the signed-in person and that they send from -their chat account. Whoever sends the code gets that chat account linked to the person it was issued -to, so treat it like a password and do not share it. It is valid for ten minutes and works once, and -issuing a new code for the same provider invalidates the earlier one. An outside account links to -one OpenBot user at most. Linking a new account at a provider and workspace replaces the person's -earlier link there and revokes its token. +or their Slack user. They appear under **Linked accounts** on `/settings/connected-accounts`, one +card per account type: the person's link with a **Disconnect** button, or, where this deployment can +link that type, a "Not linked" card with **Connect**. The section is hidden when nothing is linked, +nothing can be linked and nothing failed to load. Disconnecting removes the link and revokes +OpenBot's stored copy of any token kept with it, and is audited. For GitHub, the grant itself stays +at GitHub until the person revokes it under GitHub, Settings, Applications. + +See "Linking Slack and GitHub accounts" below for setting up each provider. An integration proves an +account in one of two ways: by the provider's own sign-in, or by a one-time code that OpenBot issues +to the signed-in person and that they send from their chat account. Whoever sends the code gets that +chat account linked to the person it was issued to, so treat it like a password and do not share it. +It is valid for ten minutes and works once, and issuing a new code for the same provider invalidates +the earlier one. An outside account links to one OpenBot user at most. Linking a new account at a +provider and workspace replaces the person's earlier link there and revokes its token. Removing a person, whether an administrator does it or a SCIM deactivation does, retires their links. Their unused link codes are deleted, any token stored with a link is revoked, and each link is @@ -510,20 +512,27 @@ link is recorded in the audit trail. ### Linking Slack and GitHub accounts -Slack requires the OpenTag pairing above, so `OPENTAG_SHARED_SECRET` must be set. The person clicks -**Link Slack**, which shows a message of the form `link `, and sends it to the OpenBot app in -Slack within ten minutes. Teams cannot be linked this way. +Slack is offered only where the OpenTag Slack webhook is set up (the pairing above, so +`OPENTAG_SHARED_SECRET` must be set) and the server can redeem identity codes. The person clicks +**Connect** on the Slack card, which shows a message of the form `link `, and sends it to the +OpenBot app in a direct message within ten minutes. A code sent in a channel is refused and stays +valid until it expires. Teams cannot be linked this way. GitHub uses a GitHub App. Create one under GitHub Settings, Developer settings, GitHub Apps, and set: - The callback URL to `/api/identity/github/callback`, where the public URL is - `OPENBOT_PUBLIC_URL` or, if that is unset, `BETTER_AUTH_URL`. + `OPENBOT_PUBLIC_URL` or, if that is unset, `BETTER_AUTH_URL`. With neither set, the server + refuses to start when `GITHUB_APP_CLIENT_ID` is present. - "Expire user authorization tokens" on. A webhook is not required for linking. - A client secret, generated on the App's page. Then set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`; set both or neither. People -then use **Connect GitHub** while signed in to OpenBot in the same browser. The -GitHub user token is stored encrypted with the link. +then use **Connect** on the GitHub card while signed in to OpenBot in the same browser. The GitHub +user token is stored encrypted with the link. + +After GitHub, the browser is sent back to `OPENBOT_APP_URL`, else the first `TRUSTED_ORIGINS` entry, +else the public URL. Set `OPENBOT_APP_URL` when the app and the server are on different origins, as +in local development (app on `3010`, server on `3001`); one origin needs nothing. Reachability codes and Linked accounts codes look alike, and Reachability's are checked first. A Reachability `link ` binds a conversation for delivery and is not recorded as a linked From 727112b8614b42ef624f400862f76d37d6d5b784 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:31:43 -0300 Subject: [PATCH 089/101] fix(server): log why each GitHub sign-in callback failed, without secrets --- server/src/identity/github-callback.ts | 66 ++++++++--- server/tests/identity-github-callback.test.ts | 107 ++++++++++++++++++ 2 files changed, 160 insertions(+), 13 deletions(-) diff --git a/server/src/identity/github-callback.ts b/server/src/identity/github-callback.ts index 562ce4a18..6675125e1 100644 --- a/server/src/identity/github-callback.ts +++ b/server/src/identity/github-callback.ts @@ -6,6 +6,7 @@ import { type CredentialStore, encryptSecret } from "../credentials"; import { exchangeGithubCode, fetchGithubUser, + GithubOAuthError, githubRedirectUri, openGithubState, } from "./github-oauth"; @@ -36,11 +37,32 @@ function logLinkAuditFailure(provider: string, error: unknown): void { ); } +function logRefused(reason: string): void { + console.warn( + JSON.stringify({ type: "identity-github-callback-refused", reason }), + ); +} + +// Only GithubOAuthError messages are logged: they are fixed sentences. Any other error's message +// could carry vendor or database text, so it contributes its name alone. +function logFailed(stage: string, error: unknown): void { + console.error( + JSON.stringify({ + type: "identity-github-callback-failed", + stage, + reason: error instanceof Error ? error.name : "unknown", + ...(error instanceof GithubOAuthError ? { message: error.message } : {}), + }), + ); +} + /** * Finishes linking a GitHub account when GitHub sends the person back. * - * Every exit is a redirect to the connected-accounts page, `?linked=github` or `?linked=failed`; - * nothing the vendor or the request carried (code, tokens, secret, state) is logged or returned. + * Every exit this router reaches is a redirect to the connected-accounts page, `?linked=github` or + * `?linked=failed`. A request with no session never gets here: requireUser answers it with a 401 + * first. Each failed exit logs one line saying which refusal or stage it was; nothing the vendor + * or the request carried (code, tokens, secret, state, account or session ids) is logged or returned. */ export function githubCallbackRoutes( deps: GithubCallbackDeps, @@ -51,24 +73,43 @@ export function githubCallbackRoutes( routes.get("/", async (context) => { const failed = () => context.redirect(`${base}failed`, 302); + let stage = "state"; try { const code = context.req.query("code"); - if (context.req.query("error") || !code) return failed(); + if (context.req.query("error")) { + logRefused("declined"); + return failed(); + } + if (!code) { + logRefused("no-code"); + return failed(); + } const state = await openGithubState( context.req.query("state"), deps.encryptionKey, now(), ); - if (!state) return failed(); + if (!state) { + logRefused("state-invalid"); + return failed(); + } // The signed-in session must be the person who started the link; otherwise a link started by // one person could attach another person's GitHub account to this session (login CSRF). const sessionUserId = context.var.actor?.id; - if (!sessionUserId || sessionUserId !== state.userId) return failed(); + if (!sessionUserId || sessionUserId !== state.userId) { + logRefused("session-mismatch"); + return failed(); + } - if (!(await deps.personIsActive(state.userId))) return failed(); + stage = "person"; + if (!(await deps.personIsActive(state.userId))) { + logRefused("inactive-person"); + return failed(); + } + stage = "exchange"; const tokens = await exchangeGithubCode( { clientId: deps.clientId, @@ -79,6 +120,7 @@ export function githubCallbackRoutes( deps.fetchImpl, now(), ); + stage = "user"; const user = await fetchGithubUser(tokens.accessToken, deps.fetchImpl); // Every connection stores its token under a key of its own, so it never collides with the @@ -86,6 +128,7 @@ export function githubCallbackRoutes( // the link names it by id, and linkVerified revokes the token the link named before in the // same transaction that repoints it. That keeps one live token per person and account, and // a link write that fails leaves the existing link and its token exactly as they were. + stage = "store"; const stored = await deps.credentials.create({ kind: "connector", provider: "github-user-token", @@ -97,6 +140,7 @@ export function githubCallbackRoutes( ), }); + stage = "link"; let link: Awaited>; try { link = await deps.identity.linkVerified( @@ -112,8 +156,9 @@ export function githubCallbackRoutes( // Only the token this attempt stored; the existing link's token was never touched. try { await deps.credentials.revoke(stored.id); - } catch { + } catch (revokeError) { // Already revoked or unreachable: the redirect below is the answer either way. + logFailed("revoke", revokeError); } throw error; } @@ -131,12 +176,7 @@ export function githubCallbackRoutes( } return context.redirect(`${base}github`, 302); } catch (error) { - console.error( - JSON.stringify({ - type: "identity-github-callback-failed", - reason: error instanceof Error ? error.name : "unknown", - }), - ); + logFailed(stage, error); return failed(); } }); diff --git a/server/tests/identity-github-callback.test.ts b/server/tests/identity-github-callback.test.ts index 6fa500e97..e9c64b323 100644 --- a/server/tests/identity-github-callback.test.ts +++ b/server/tests/identity-github-callback.test.ts @@ -21,6 +21,9 @@ function build( actorId?: string | null; active?: boolean; tokenFails?: boolean; + userFails?: boolean; + createError?: Error; + activeError?: Error; linkError?: Error; revokeError?: Error; auditError?: Error; @@ -45,6 +48,7 @@ function build( refresh_token_expires_in: 15897600, }); } + if (options.userFails) return new Response("{}", { status: 401 }); return Response.json({ id: 42, login: "dana" }); }) as unknown as typeof fetch; @@ -56,10 +60,12 @@ function build( encryptionKey: key, personIsActive: async () => { activeChecks += 1; + if (options.activeError) throw options.activeError; return options.active ?? true; }, credentials: { create: async (value) => { + if (options.createError) throw options.createError; created.push(value); return { id: "cred-new", revokedAt: null }; }, @@ -337,6 +343,7 @@ describe("GitHub sign-in callback", () => { await call(harness, `code=the-code&state=${await validState()}`), ).toBe(FAILED); expect(harness.audits).toHaveLength(0); + expect(harness.revoked).toEqual(["cred-new"]); }); test("nothing secret is ever logged", async () => { @@ -353,6 +360,8 @@ describe("GitHub sign-in callback", () => { await call(harness, `code=the-code&state=${state}`); } const text = output(); + expect(text).not.toBe(""); + expect(errors.mock.calls.length).toBeGreaterThan(0); for (const secret of [ "the-code", "gho_secret", @@ -364,4 +373,102 @@ describe("GitHub sign-in callback", () => { expect(text).not.toContain(secret); } }); + + describe("diagnostics", () => { + function lines() { + return [ + ...errors.mock.calls.map((args: unknown[]) => String(args[0])), + ...warns.mock.calls.map((args: unknown[]) => String(args[0])), + ].map((line) => JSON.parse(line)); + } + + const refusals: [string, () => Promise<[Harness, string]>][] = [ + [ + "declined", + async () => [ + build(), + `error=access_denied&state=${await validState()}`, + ], + ], + ["no-code", async () => [build(), `state=${await validState()}`]], + ["state-invalid", async () => [build(), "code=the-code"]], + [ + "session-mismatch", + async () => [ + build({ actorId: "someone-else" }), + `code=the-code&state=${await validState()}`, + ], + ], + [ + "inactive-person", + async () => [ + build({ active: false }), + `code=the-code&state=${await validState()}`, + ], + ], + ]; + for (const [reason, setup] of refusals) { + test(`logs a refusal: ${reason}`, async () => { + const [harness, query] = await setup(); + expect(await call(harness, query)).toBe(FAILED); + expect(lines()).toEqual([ + { type: "identity-github-callback-refused", reason }, + ]); + }); + } + + const failures: [string, Parameters[0], object][] = [ + [ + "exchange", + { tokenFails: true }, + { + reason: "GithubOAuthError", + message: "GitHub refused the authorization code", + }, + ], + [ + "user", + { userFails: true }, + { + reason: "GithubOAuthError", + message: "GitHub refused to identify the account", + }, + ], + [ + "store", + { createError: new TypeError("db down") }, + { reason: "TypeError" }, + ], + [ + "link", + { linkError: new IdentityConflictError() }, + { reason: "IdentityConflictError" }, + ], + [ + "person", + { activeError: new RangeError("x") }, + { reason: "RangeError" }, + ], + ]; + for (const [stage, options, extra] of failures) { + test(`logs a failure at stage ${stage}`, async () => { + const harness = build(options); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(lines()).toEqual([ + { type: "identity-github-callback-failed", stage, ...extra }, + ]); + }); + } + + test("logs a revoke cleanup that fails, besides the link failure", async () => { + const harness = build({ + linkError: new IdentityConflictError(), + revokeError: new Error("already revoked"), + }); + await call(harness, `code=the-code&state=${await validState()}`); + expect(lines().map((line) => line.stage)).toEqual(["revoke", "link"]); + }); + }); }); From 55ae763e5fa1a309859136158c917af6bda38c7f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:31:54 -0300 Subject: [PATCH 090/101] docs(code): make linking comments match what the code does --- .../_authed/settings/connected-accounts/index.tsx | 11 +++++++---- server/src/app.ts | 3 ++- server/src/delivery/opentag-agent.ts | 4 +++- server/src/identity/slack-redeem.ts | 4 ++-- server/tests/delivery-routes.test.ts | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 9466d59fb..7371fd851 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -47,8 +47,10 @@ export const Route = createFileRoute("/_authed/settings/connected-accounts/")({ component: RouteComponent, /* * `?connected=` is how the OAuth callback reports back, carrying a server key on success and - * `failed` otherwise. It is the only channel available: the callback is a redirect from another - * company's server, so there is no response body to read. + * `failed` otherwise. `?linked=` is the same for the GitHub account-link callback: `github` on + * success, `failed` otherwise (see linked-notice.ts). Each is the only + * channel available: the callback is a redirect from another company's server, so there is no + * response body to read. * * 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. @@ -181,8 +183,9 @@ function RouteComponent() { title="Connected accounts" > {/* - * Only the failure is worth saying. A success needs no sentence: the row it came back to now - * reads "Connected", which is the same news told by the thing it is news about. + * For `?connected=` only the failure is worth saying. A success needs no sentence: the row it + * 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" ? (

diff --git a/server/src/app.ts b/server/src/app.ts index b84dbe65c..58422e1d7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -687,7 +687,8 @@ export function createApp( * The session must match the sealed state's person, otherwise a link started by one person could * attach another person's GitHub account (login CSRF), so the callback requires the same session * as the rest of /api/identity; a missing session answers 401 rather than a redirect. Mounted - * before the identity router so that router never sees this path. + * before the identity router so that router never sees a GET to this path; other methods fall + * through to it and get its own 401/404. */ if (identityGithubCallback) { app.use("/api/identity/github/callback", requireUser); diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 9f2b46025..026850756 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -94,7 +94,9 @@ export type OpenTagAgentDeps = { ingestSlack?: (event: SlackTriggerEvent) => Promise; /** * Redeems OpenBot linked-account codes (Settings → Connected accounts) for a Slack sender. - * Reachability codes are always checked first; this only sees codes that are not one. + * Reachability codes are checked first; this is tried for any code that is not a live one (an + * expired or used Reachability code falls through too, matches no account code, and gets the usual + * expired reply). Account codes are honoured only in direct messages. */ identity?: { redeem( diff --git a/server/src/identity/slack-redeem.ts b/server/src/identity/slack-redeem.ts index 9b85e2984..fbe7666f0 100644 --- a/server/src/identity/slack-redeem.ts +++ b/server/src/identity/slack-redeem.ts @@ -8,8 +8,8 @@ import { } from "./types"; /** - * Redeems a link code a person sent from Slack. Failures a person can cause become a result; any - * other error propagates. The audit event is written after the link commits; a failed write is + * Redeems a link code a person sent from Slack. Failures a person can cause (a bad or used code, or a + * blank or rejected team id, all reported as "invalid") become a result; any other error propagates. The audit event is written after the link commits; a failed write is * logged and the person is still told they are linked. It names the link and provider, never the realm or Slack subject. */ export function slackCodeRedeemer( diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index ac438170e..4a159493a 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -305,7 +305,7 @@ test("a Reachability code still links from a shared conversation", async () => { expect(said(result.events)).toContain("Linked."); expect(redeemed).toEqual([]); }); -test("an identity code conflict names the other user", async () => { +test("an identity code conflict gets the already-linked reply", async () => { const { f } = identityFixture("conflict"); const result = await run(f, { context: [sender()], From 725122cf9738f0ec46baf1a09f477225f76e1335 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:32:35 -0300 Subject: [PATCH 091/101] fix(app): keep Connect GitHub disabled while leaving for GitHub --- .../components/settings/linked-accounts.test.tsx | 13 +++++++++++++ app/src/components/settings/linked-accounts.tsx | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/components/settings/linked-accounts.test.tsx b/app/src/components/settings/linked-accounts.test.tsx index 7c917918a..a81f6f775 100644 --- a/app/src/components/settings/linked-accounts.test.tsx +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -3,6 +3,7 @@ import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { unlinkOutcome } from "@/lib/identity/mutations"; import { linksFromResponse } from "@/lib/identity/queries"; import { + connectGithubPending, copyText, linkedAccountDescription, linkedAccountsSectionState, @@ -334,3 +335,15 @@ test("copying reports failure when the write is rejected", async () => { }); expect(ok).toBe(false); }); + +test("the GitHub Connect button stays disabled from the request until the page leaves", () => { + expect(connectGithubPending({ isPending: false, isSuccess: false })).toBe( + false, + ); + expect(connectGithubPending({ isPending: true, isSuccess: false })).toBe( + true, + ); + expect(connectGithubPending({ isPending: false, isSuccess: true })).toBe( + true, + ); +}); diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index 743163870..7862ee447 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -120,6 +120,19 @@ export function linkedAccountsSectionState({ }; } +/** + * Whether the GitHub Connect button is disabled. + * + * The mutation settles before the browser has left for GitHub, so a success keeps the button + * disabled too: the page is about to navigate away and a second click would start a second sign-in. + */ +export function connectGithubPending(mutation: { + isPending: boolean; + isSuccess: boolean; +}): boolean { + return mutation.isPending || mutation.isSuccess; +} + /** * Who you are in the chat apps and code hosts this deployment talks to. * @@ -157,7 +170,7 @@ export function LinkedAccountsSection() { issueCode.mutate(undefined, { onSuccess: (code) => setIssued(code) }), }, github: { - pending: connectGithub.isPending, + pending: connectGithubPending(connectGithub), start: () => connectGithub.mutate(undefined, { onSuccess: (url) => window.location.assign(url), From e9009ab4928d46840d6df4192bbf4a7537bec118 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:32:38 -0300 Subject: [PATCH 092/101] fix: tell people when a GitHub account is already linked to someone else --- .../components/settings/linked-notice.test.ts | 7 +++++++ app/src/components/settings/linked-notice.ts | 8 +++++++- server/src/identity/github-callback.ts | 11 +++++++++-- ...dentity-github-callback.integration.test.ts | 6 ++++-- server/tests/identity-github-callback.test.ts | 18 ++++++++++++++---- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/app/src/components/settings/linked-notice.test.ts b/app/src/components/settings/linked-notice.test.ts index 7bde1471d..0ae803cf2 100644 --- a/app/src/components/settings/linked-notice.test.ts +++ b/app/src/components/settings/linked-notice.test.ts @@ -16,6 +16,13 @@ describe("linkedNotice", () => { }); }); + test("tells the person the GitHub account belongs to another user, as an alert", () => { + expect(linkedNotice("github-taken")).toEqual({ + tone: "error", + text: "That GitHub account is already linked to another OpenBot user. Disconnect it there first, or connect a different GitHub account.", + }); + }); + test.each([undefined, "", "slack", "__proto__"])( "says nothing for %p", (value) => { diff --git a/app/src/components/settings/linked-notice.ts b/app/src/components/settings/linked-notice.ts index 02472c581..683cbfcb3 100644 --- a/app/src/components/settings/linked-notice.ts +++ b/app/src/components/settings/linked-notice.ts @@ -2,7 +2,7 @@ export type LinkedNotice = { tone: "success" | "error"; text: string }; /** * What the connected accounts page says about the `?linked=` the GitHub callback redirected back - * with. Only the two values the callback sends have a sentence; anything else says nothing. + * with. Only the three values the callback sends have a sentence; anything else says nothing. */ export function linkedNotice(value: string | undefined): LinkedNotice | null { if (value === "github") { @@ -14,5 +14,11 @@ export function linkedNotice(value: string | undefined): LinkedNotice | null { text: "That account could not be linked. Nothing was saved — try again.", }; } + if (value === "github-taken") { + return { + tone: "error", + text: "That GitHub account is already linked to another OpenBot user. Disconnect it there first, or connect a different GitHub account.", + }; + } return null; } diff --git a/server/src/identity/github-callback.ts b/server/src/identity/github-callback.ts index 6675125e1..a8216a16b 100644 --- a/server/src/identity/github-callback.ts +++ b/server/src/identity/github-callback.ts @@ -12,6 +12,7 @@ import { } from "./github-oauth"; import { GITHUB_DOTCOM_REALM } from "./providers"; import type { IdentityStore } from "./store"; +import { IdentityConflictError } from "./types"; export type GithubCallbackDeps = { clientId: string; @@ -59,8 +60,8 @@ function logFailed(stage: string, error: unknown): void { /** * Finishes linking a GitHub account when GitHub sends the person back. * - * Every exit this router reaches is a redirect to the connected-accounts page, `?linked=github` or - * `?linked=failed`. A request with no session never gets here: requireUser answers it with a 401 + * Every exit this router reaches is a redirect to the connected-accounts page, `?linked=github`, + * `?linked=github-taken` (the account is already linked to another person) or `?linked=failed`. A request with no session never gets here: requireUser answers it with a 401 * first. Each failed exit logs one line saying which refusal or stage it was; nothing the vendor * or the request carried (code, tokens, secret, state, account or session ids) is logged or returned. */ @@ -160,6 +161,12 @@ export function githubCallbackRoutes( // Already revoked or unreachable: the redirect below is the answer either way. logFailed("revoke", revokeError); } + // The account belongs to a different person. Say so, without saying whose: retrying can + // never work, so `?linked=failed` ("try again") would mislead. + if (error instanceof IdentityConflictError) { + logFailed("link", error); + return context.redirect(`${base}github-taken`, 302); + } throw error; } diff --git a/server/tests/identity-github-callback.integration.test.ts b/server/tests/identity-github-callback.integration.test.ts index 5ce1ff671..2383fcdd7 100644 --- a/server/tests/identity-github-callback.integration.test.ts +++ b/server/tests/identity-github-callback.integration.test.ts @@ -14,6 +14,8 @@ import { TEST_POOL, testDatabaseUrl } from "./support/database"; const encryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; const OK = "https://app.test/settings/connected-accounts?linked=github"; const FAILED = "https://app.test/settings/connected-accounts?linked=failed"; +const TAKEN = + "https://app.test/settings/connected-accounts?linked=github-taken"; const database = createDatabase(testDatabaseUrl(), TEST_POOL); const vault = createCredentialStore(database); @@ -233,7 +235,7 @@ test("a reconnect whose link write fails leaves the existing link and its token expect(live.map((row) => row.id)).toEqual([before?.credentialId as string]); }); -test("an account already linked to someone else fails and revokes the new token", async () => { +test("an account already linked to someone else says so and revokes the new token", async () => { const account = githubAccount(); const owner = person(); const intruder = person(); @@ -250,7 +252,7 @@ test("an account already linked to someone else fails and revokes the new token" ); expect(response.status).toBe(302); - expect(response.headers.get("location")).toBe(FAILED); + expect(response.headers.get("location")).toBe(TAKEN); const after = await linkFor(account); expect(after).toMatchObject({ userId: owner, diff --git a/server/tests/identity-github-callback.test.ts b/server/tests/identity-github-callback.test.ts index e9c64b323..e4334c5f3 100644 --- a/server/tests/identity-github-callback.test.ts +++ b/server/tests/identity-github-callback.test.ts @@ -13,6 +13,8 @@ const now = new Date("2026-01-01T00:00:00.000Z"); const clientSecret = "client-secret-value"; const OK = "https://app.test/settings/connected-accounts?linked=github"; const FAILED = "https://app.test/settings/connected-accounts?linked=failed"; +const TAKEN = + "https://app.test/settings/connected-accounts?linked=github-taken"; type Harness = ReturnType; @@ -329,19 +331,27 @@ describe("GitHub sign-in callback", () => { }); expect( await call(harness, `code=the-code&state=${await validState()}`), - ).toBe(FAILED); + ).toBe(TAKEN); expect(harness.revoked).toEqual(["cred-new"]); expect(harness.audits).toHaveLength(0); }); - test("a revoke that throws still redirects failed", async () => { + test("any other link failure still redirects failed and revokes the new credential", async () => { + const harness = build({ linkError: new Error("database unreachable") }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(FAILED); + expect(harness.revoked).toEqual(["cred-new"]); + }); + + test("a revoke that throws still redirects github-taken", async () => { const harness = build({ linkError: new IdentityConflictError(), revokeError: new Error("already revoked"), }); expect( await call(harness, `code=the-code&state=${await validState()}`), - ).toBe(FAILED); + ).toBe(TAKEN); expect(harness.audits).toHaveLength(0); expect(harness.revoked).toEqual(["cred-new"]); }); @@ -455,7 +465,7 @@ describe("GitHub sign-in callback", () => { const harness = build(options); expect( await call(harness, `code=the-code&state=${await validState()}`), - ).toBe(FAILED); + ).toBe(stage === "link" ? TAKEN : FAILED); expect(lines()).toEqual([ { type: "identity-github-callback-failed", stage, ...extra }, ]); From 95ed3aa6e80101658e1a56eb81e528e3817ce8d4 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:33:05 -0300 Subject: [PATCH 093/101] fix(app): re-enable Connect GitHub when the page comes back from GitHub --- app/src/components/settings/linked-accounts.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index 7862ee447..eb9437467 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -1,6 +1,6 @@ import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { PageRows, PageSection } from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; import { @@ -147,6 +147,16 @@ export function LinkedAccountsSection() { const [issued, setIssued] = useState(null); const [copied, setCopied] = useState(false); const [copyFailed, setCopyFailed] = useState(false); + // Back from GitHub can restore this page from the back/forward cache with the successful + // connect still in memory, which would leave Connect disabled; a restored page starts over. + const resetConnect = connectGithub.reset; + useEffect(() => { + const restored = (event: PageTransitionEvent) => { + if (event.persisted) resetConnect(); + }; + window.addEventListener("pageshow", restored); + return () => window.removeEventListener("pageshow", restored); + }, [resetConnect]); const closeDialog = () => { setIssued(null); setCopied(false); From 771fd0dd233002d7d320e1bf07a8a728c9d9b4bd Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:33:05 -0300 Subject: [PATCH 094/101] docs(code): list github-taken among the ?linked= outcomes --- .../routes/_authed/settings/connected-accounts/index.tsx | 6 +++--- 1 file 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 7371fd851..5ee584e20 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -48,9 +48,9 @@ export const Route = createFileRoute("/_authed/settings/connected-accounts/")({ /* * `?connected=` is how the OAuth callback reports back, carrying a server key on success and * `failed` otherwise. `?linked=` is the same for the GitHub account-link callback: `github` on - * success, `failed` otherwise (see linked-notice.ts). Each is the only - * channel available: the callback is a redirect from another company's server, so there is no - * response body to read. + * success, `github-taken` when that GitHub account belongs to another OpenBot user, `failed` + * otherwise (see linked-notice.ts). Each is the only channel available: the callback is a + * redirect from another company's server, so there is no response body to read. * * 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. From 8d833640b099be1a07fac8ef26be2cad442c5973 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:43:34 -0300 Subject: [PATCH 095/101] fix(app): drop the linked notice once that account is disconnected --- .../components/settings/linked-notice.test.ts | 34 ++++++++++++++++++- app/src/components/settings/linked-notice.ts | 17 ++++++++++ .../settings/connected-accounts/index.tsx | 11 ++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/app/src/components/settings/linked-notice.test.ts b/app/src/components/settings/linked-notice.test.ts index 0ae803cf2..4089f901c 100644 --- a/app/src/components/settings/linked-notice.test.ts +++ b/app/src/components/settings/linked-notice.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { linkedNotice } from "./linked-notice"; +import { linkedNotice, visibleLinkedNotice } from "./linked-notice"; describe("linkedNotice", () => { test("reports a linked GitHub account", () => { @@ -30,3 +30,35 @@ describe("linkedNotice", () => { }, ); }); + +describe("visibleLinkedNotice", () => { + const success = linkedNotice("github"); + const github = { provider: "github" as const }; + const slack = { provider: "slack" as const }; + + test("shows the success notice while a GitHub link exists", () => { + expect(visibleLinkedNotice(success, [slack, github])).toEqual(success); + }); + + test("hides the success notice once the GitHub link is gone", () => { + expect(visibleLinkedNotice(success, [slack])).toBeNull(); + expect(visibleLinkedNotice(success, [])).toBeNull(); + }); + + test("hides the success notice until the links have loaded", () => { + expect(visibleLinkedNotice(success, undefined)).toBeNull(); + }); + + test.each(["failed", "github-taken"])( + "leaves the %s error notice alone whatever the links say", + (value) => { + const notice = linkedNotice(value); + expect(visibleLinkedNotice(notice, [])).toEqual(notice); + expect(visibleLinkedNotice(notice, undefined)).toEqual(notice); + }, + ); + + test("passes no notice through as none", () => { + expect(visibleLinkedNotice(null, [github])).toBeNull(); + }); +}); diff --git a/app/src/components/settings/linked-notice.ts b/app/src/components/settings/linked-notice.ts index 683cbfcb3..53b72c7ae 100644 --- a/app/src/components/settings/linked-notice.ts +++ b/app/src/components/settings/linked-notice.ts @@ -22,3 +22,20 @@ export function linkedNotice(value: string | undefined): LinkedNotice | null { } return null; } + +/** + * The notice to draw right now, given the person's current linked accounts. + * + * A success notice is a claim about the list below it, so it holds only while that list agrees: the + * only success `linkedNotice` produces is for GitHub, and once that link is gone (the person + * disconnected it on this very page) the sentence would contradict the card under it. Until the list + * has loaded (`links` undefined) it stays hidden, which beats flashing a claim that may be wrong. + * Error notices describe an attempt, not the list, so they are never withheld. + */ +export function visibleLinkedNotice( + notice: LinkedNotice | null, + links: ReadonlyArray<{ provider: string }> | undefined, +): LinkedNotice | null { + if (!notice || notice.tone === "error") return notice; + return links?.some((link) => link.provider === "github") ? notice : null; +} diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index 5ee584e20..fd6a25d4d 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -16,7 +16,10 @@ import { import { RowMark } from "@/components/layout/row-mark"; import { PluginLogo } from "@/components/plugins/plugin-logo"; import { LinkedAccountsSection } from "@/components/settings/linked-accounts"; -import { linkedNotice } from "@/components/settings/linked-notice"; +import { + linkedNotice, + visibleLinkedNotice, +} from "@/components/settings/linked-notice"; import { InputGroup, InputGroupAddon, @@ -29,6 +32,7 @@ import { ItemDescription, ItemTitle, } from "@/components/ui/item"; +import { linkedAccountsQueryOptions } from "@/lib/identity/queries"; import { connectionsQueryOptions, type PluginServer, @@ -92,7 +96,10 @@ export function brokeredAccountsListedOn( function RouteComponent() { const { connected: outcome, linked } = Route.useSearch(); /* Read once, then dropped from the URL, so a refresh does not repeat the news. */ - const [notice] = React.useState(() => linkedNotice(linked)); + const [arrival] = React.useState(() => 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 navigate = Route.useNavigate(); React.useEffect(() => { if (linked !== undefined) { From c07c0a289e6beed00e856e059e307c4fa9977bef Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:43:51 -0300 Subject: [PATCH 096/101] fix(server): cancel a Slack link code that was posted in a channel --- server/src/delivery/opentag-agent.ts | 27 ++++++++---- server/src/delivery/routes.ts | 2 +- server/src/identity/providers.ts | 2 +- server/src/identity/slack-redeem.ts | 7 ++- server/src/identity/store.ts | 24 +++++++++++ server/tests/delivery-routes.test.ts | 43 +++++++++++++++---- .../identity-challenges.integration.test.ts | 30 +++++++++++++ server/tests/identity-providers.test.ts | 4 +- server/tests/identity-slack-redeem.test.ts | 41 ++++++++++++++++-- 9 files changed, 154 insertions(+), 26 deletions(-) diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 026850756..298539a07 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -93,16 +93,20 @@ export type OpenTagAgentDeps = { /** Slack trigger ingest; defaults to the responsibilities lane's `ingestSlackEvent`. */ ingestSlack?: (event: SlackTriggerEvent) => Promise; /** - * Redeems OpenBot linked-account codes (Settings → Connected accounts) for a Slack sender. - * Reachability codes are checked first; this is tried for any code that is not a live one (an - * expired or used Reachability code falls through too, matches no account code, and gets the usual - * expired reply). Account codes are honoured only in direct messages. + * OpenBot linked-account codes (Settings → Connected accounts) from a Slack sender. Reachability + * codes are checked first; this is used for any code that is not a live one (an expired or used + * Reachability code falls through too, matches no account code, and gets the usual expired reply). + * An account code is redeemed only in a direct message. One posted in a shared conversation is + * cancelled (`discard`), since anyone there could send it first and bind their own Slack account + * to its issuer; the issuer is told to make a new one. */ identity?: { redeem( code: string, sender: { teamId: string; userId: string }, ): Promise<"linked" | "conflict" | "invalid">; + /** Cancels a live account code; true when one was cancelled. */ + discard(code: string): Promise; }; }; @@ -374,13 +378,18 @@ async function link( ) { const challenge = await deps.store.readChallenge(code); if (!challenge && sender.transport === "slack" && deps.identity) { - // Anyone else in a shared conversation could send the pasted code first and bind their own - // Slack account to its issuer, so an account code is only redeemed in a direct message and is - // left unspent here for its owner. - if (!sender.private) + // Anyone else in a shared conversation has now seen the code and could send it first, binding + // their own Slack account to its issuer, so an account code is only redeemed in a direct + // message and one posted here is cancelled. + if (!sender.private) { + if (await deps.identity.discard(code)) + return { + text: "That code was posted where others could see it, so I cancelled it. Create a new one in OpenBot and send it to me in a direct message.", + }; return { - text: "Send your link code to me in a direct message, not in a channel. It still works until it expires.", + text: "That link code expired, was already used, or is for another app. Create a new one in OpenBot.", }; + } const outcome = await deps.identity.redeem(code, { teamId: sender.realm, userId: sender.identity, diff --git a/server/src/delivery/routes.ts b/server/src/delivery/routes.ts index 3823ede4c..15a11b5ab 100644 --- a/server/src/delivery/routes.ts +++ b/server/src/delivery/routes.ts @@ -281,7 +281,7 @@ export function createDeliveryWebhookRoutes(deps: { approvals?: PairedApprovals; /** Test seam; production uses the responsibilities lane's `ingestSlackEvent`. */ ingestSlack?: OpenTagAgentDeps["ingestSlack"]; - /** Redeems OpenBot linked-account codes sent to the Slack app. */ + /** Redeems OpenBot linked-account codes sent to the Slack app in a DM; cancels one posted in a channel. */ identity?: OpenTagAgentDeps["identity"]; }) { const routes = new Hono<{ Variables: AppVariables }>(); diff --git a/server/src/identity/providers.ts b/server/src/identity/providers.ts index 83833cd27..044b29499 100644 --- a/server/src/identity/providers.ts +++ b/server/src/identity/providers.ts @@ -26,7 +26,7 @@ export const PROVIDERS: Readonly> = title: "Slack", methods: ["challenge"], instruction: (code) => - `Send this message to the OpenBot bot in Slack: link ${code}`, + `Send this message to the OpenBot app in a direct message in Slack: link ${code}`, }, github: { title: "GitHub", diff --git a/server/src/identity/slack-redeem.ts b/server/src/identity/slack-redeem.ts index fbe7666f0..ebad9544c 100644 --- a/server/src/identity/slack-redeem.ts +++ b/server/src/identity/slack-redeem.ts @@ -8,20 +8,23 @@ import { } from "./types"; /** - * Redeems a link code a person sent from Slack. Failures a person can cause (a bad or used code, or a + * Redeems a link code a person sent from Slack, or (`discard`) cancels one they exposed in a shared + * conversation, reporting whether a live Slack code was cancelled. Failures a person can cause (a bad or used code, or a * blank or rejected team id, all reported as "invalid") become a result; any other error propagates. The audit event is written after the link commits; a failed write is * logged and the person is still told they are linked. It names the link and provider, never the realm or Slack subject. */ export function slackCodeRedeemer( - store: Pick, + store: Pick, auditStore?: AuditStore, ): { redeem( code: string, sender: { teamId: string; userId: string }, ): Promise<"linked" | "conflict" | "invalid">; + discard(code: string): Promise; } { return { + discard: (code) => store.discardChallenge(code, "slack"), async redeem(code, sender) { let link: Awaited>; try { diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts index ad473bc14..215f40613 100644 --- a/server/src/identity/store.ts +++ b/server/src/identity/store.ts @@ -537,6 +537,30 @@ export function createIdentityStore(database: Database, credentials: Revoke) { }); }, + /** + * Cancel a live `provider` code without linking anything, for a code exposed where others could + * read it. Returns whether a live code was removed: false for a malformed, unknown, expired or + * other-provider code, which is left as it was. + */ + async discardChallenge( + code: string, + provider: IdentityProvider, + ): Promise { + if (typeof code !== "string" || !UUID.test(code)) return false; + const hash = codeHash(code); + const removed = await database + .delete(identityLinkChallenges) + .where( + and( + eq(identityLinkChallenges.tokenHash, hash), + eq(identityLinkChallenges.provider, provider), + gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), + ), + ) + .returning({ provider: identityLinkChallenges.provider }); + return removed.length > 0; + }, + /** * Link `value`, the chat account the code arrived from, to the user the code was issued to. * Consumption and link creation share one transaction: a refused link leaves the code unused. diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 4a159493a..b3818b9f5 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -25,6 +25,7 @@ type IdentityAdapter = { code: string, sender: { teamId: string; userId: string }, ): Promise<"linked" | "conflict" | "invalid">; + discard(code: string): Promise; }; function fixture( options: { @@ -239,8 +240,12 @@ test("a link code binds the verified sender to the challenge's own scope", async const IDENTITY_CODE = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; const EXPIRED = "That link code expired, was already used, or is for another app. Create a new one in OpenBot."; -function identityFixture(outcome?: "linked" | "conflict" | "invalid") { +function identityFixture( + outcome?: "linked" | "conflict" | "invalid", + live = true, +) { const redeemed: unknown[] = []; + const discarded: string[] = []; const f = fixture({ bound: false, ...(outcome @@ -250,11 +255,15 @@ function identityFixture(outcome?: "linked" | "conflict" | "invalid") { redeemed.push([code, who]); return outcome; }, + discard: async (code) => { + discarded.push(code); + return live; + }, }, } : {}), }); - return { f, redeemed }; + return { f, redeemed, discarded }; } const linkMessage = (code: string) => ({ messages: [ @@ -282,28 +291,46 @@ test("an identity code from a Slack sender is redeemed through the adapter", asy ); expect(redeemed).toEqual([[IDENTITY_CODE, { teamId: "T1", userId: "U1" }]]); }); -test("an identity code sent where others can read it is not redeemed", async () => { +const CANCELLED = + "That code was posted where others could see it, so I cancelled it. Create a new one in OpenBot and send it to me in a direct message."; +test("an identity code sent where others can read it is cancelled, never redeemed", async () => { for (const conversation of [{ id: "C0123456789", kind: "channel" }, null]) { - const { f, redeemed } = identityFixture("linked"); + const { f, redeemed, discarded } = identityFixture("linked"); const result = await run(f, { context: [sender("slack:T1:U1", conversation)], ...linkMessage(IDENTITY_CODE), }); - expect(said(result.events)).toBe( - "Send your link code to me in a direct message, not in a channel. It still works until it expires.", - ); + expect(said(result.events)).toBe(CANCELLED); + expect(said(result.events)).not.toContain("still works"); + expect(discarded).toEqual([IDENTITY_CODE]); expect(redeemed).toEqual([]); expect(f.calls).toEqual([]); } }); +test("an unknown or expired code in a shared conversation gets the expired reply", async () => { + const { f, redeemed, discarded } = identityFixture("linked", false); + const result = await run(f, { + context: [sender("slack:T1:U1", { id: "C0123456789", kind: "channel" })], + ...linkMessage(IDENTITY_CODE), + }); + expect(said(result.events)).toBe(EXPIRED); + expect(discarded).toEqual([IDENTITY_CODE]); + expect(redeemed).toEqual([]); +}); +test("an identity code in a direct message is never discarded", async () => { + const { f, discarded } = identityFixture("linked"); + await run(f, { context: [sender()], ...linkMessage(IDENTITY_CODE) }); + expect(discarded).toEqual([]); +}); test("a Reachability code still links from a shared conversation", async () => { - const { f, redeemed } = identityFixture("linked"); + const { f, redeemed, discarded } = identityFixture("linked"); const result = await run(f, { context: [sender("slack:T1:U1", { id: "C0123456789", kind: "channel" })], ...linkMessage("11111111-2222-4333-8444-555555555555"), }); expect(said(result.events)).toContain("Linked."); expect(redeemed).toEqual([]); + expect(discarded).toEqual([]); }); test("an identity code conflict gets the already-linked reply", async () => { const { f } = identityFixture("conflict"); diff --git a/server/tests/identity-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts index bdf790b64..ea25c54f3 100644 --- a/server/tests/identity-challenges.integration.test.ts +++ b/server/tests/identity-challenges.integration.test.ts @@ -339,3 +339,33 @@ test.each([42, undefined, " user-padded", "user-padded "])( ).rejects.toThrow(IdentityInputError); }, ); + +test("discarding a live code removes it, so it can no longer link", async () => { + const { code } = await store.issueChallenge(user(), "slack"); + expect(await store.discardChallenge(code.toUpperCase(), "slack")).toBe(true); + expect(await rowFor(code)).toBeUndefined(); + const sender = slack(); + await expect(store.redeemChallenge(code, sender)).rejects.toThrow( + IdentityLinkError, + ); + expect(await store.linkedUser(sender)).toBeNull(); + expect(await store.discardChallenge(code, "slack")).toBe(false); +}); + +test("discarding an unknown, malformed or expired code finds nothing", async () => { + expect(await store.discardChallenge(randomUUID(), "slack")).toBe(false); + expect(await store.discardChallenge("not-a-code", "slack")).toBe(false); + const { code } = await store.issueChallenge(user(), "slack"); + await expire(code); + expect(await store.discardChallenge(code, "slack")).toBe(false); +}); + +test("discarding for another provider leaves the code working", async () => { + const owner = user(); + const { code } = await store.issueChallenge(owner, "slack"); + expect(await store.discardChallenge(code, "github" as IdentityProvider)).toBe( + false, + ); + expect(await rowFor(code)).toBeDefined(); + expect((await store.redeemChallenge(code, slack())).userId).toBe(owner); +}); diff --git a/server/tests/identity-providers.test.ts b/server/tests/identity-providers.test.ts index 5dda798cf..2abc77e3e 100644 --- a/server/tests/identity-providers.test.ts +++ b/server/tests/identity-providers.test.ts @@ -50,10 +50,10 @@ test("each provider declares the proofs it accepts", () => { expect(acceptsMethod("github", "challenge")).toBe(false); }); -test("Slack tells the person to send the code to the bot", () => { +test("Slack tells the person to send the code to the app in a direct message", () => { const code = "123e4567-e89b-42d3-a456-426614174000"; expect(PROVIDERS.slack.instruction?.(code)).toBe( - `Send this message to the OpenBot bot in Slack: link ${code}`, + `Send this message to the OpenBot app in a direct message in Slack: link ${code}`, ); expect(PROVIDERS.github.instruction).toBeUndefined(); }); diff --git a/server/tests/identity-slack-redeem.test.ts b/server/tests/identity-slack-redeem.test.ts index 5468762d5..d8bd79cf2 100644 --- a/server/tests/identity-slack-redeem.test.ts +++ b/server/tests/identity-slack-redeem.test.ts @@ -11,21 +11,29 @@ import { const sender = { teamId: "T1", userId: "U1" }; -function setup(behavior: () => Promise) { +function setup( + behavior: () => Promise, + discarded: () => Promise = async () => true, +) { const calls: unknown[][] = []; + const discards: unknown[][] = []; const events: AuditEventInput[] = []; const store = { redeemChallenge: async (...args: unknown[]) => { calls.push(args); return behavior(); }, - } as unknown as Pick; + discardChallenge: async (...args: unknown[]) => { + discards.push(args); + return discarded(); + }, + } as unknown as Pick; const auditStore: AuditStore = { insert: async (event) => { events.push(event); }, }; - return { calls, events, store, auditStore }; + return { calls, discards, events, store, auditStore }; } const link = { @@ -144,4 +152,31 @@ describe("slackCodeRedeemer", () => { const { store } = setup(async () => link); expect(await slackCodeRedeemer(store).redeem("X", sender)).toBe("linked"); }); + + test("discard cancels a Slack code and reports whether one existed", async () => { + for (const existed of [true, false]) { + const { calls, discards, events, store, auditStore } = setup( + async () => link, + async () => existed, + ); + expect(await slackCodeRedeemer(store, auditStore).discard("CODE")).toBe( + existed, + ); + expect(discards).toEqual([["CODE", "slack"]]); + expect(calls).toHaveLength(0); + expect(events).toHaveLength(0); + } + }); + + test("discard propagates a store failure", async () => { + const { store } = setup( + async () => link, + async () => { + throw new Error("db down"); + }, + ); + await expect(slackCodeRedeemer(store).discard("CODE")).rejects.toThrow( + "db down", + ); + }); }); From c89ecb723857ec44c3555f066fe9893b413f01b9 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:44:03 -0300 Subject: [PATCH 097/101] fix(app): give a usable next step when a GitHub account is taken --- app/src/components/settings/linked-notice.test.ts | 4 ++-- app/src/components/settings/linked-notice.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/components/settings/linked-notice.test.ts b/app/src/components/settings/linked-notice.test.ts index 4089f901c..63ad78076 100644 --- a/app/src/components/settings/linked-notice.test.ts +++ b/app/src/components/settings/linked-notice.test.ts @@ -12,14 +12,14 @@ describe("linkedNotice", () => { test("reports a failed link", () => { expect(linkedNotice("failed")).toEqual({ tone: "error", - text: "That account could not be linked. Nothing was saved — try again.", + text: "Your GitHub account could not be linked. Try again.", }); }); test("tells the person the GitHub account belongs to another user, as an alert", () => { expect(linkedNotice("github-taken")).toEqual({ tone: "error", - text: "That GitHub account is already linked to another OpenBot user. Disconnect it there first, or connect a different GitHub account.", + text: "That GitHub account is already linked to another OpenBot user. Sign in to GitHub as a different account and connect again, or ask your OpenBot administrator for help.", }); }); diff --git a/app/src/components/settings/linked-notice.ts b/app/src/components/settings/linked-notice.ts index 53b72c7ae..6063e99ad 100644 --- a/app/src/components/settings/linked-notice.ts +++ b/app/src/components/settings/linked-notice.ts @@ -11,13 +11,13 @@ export function linkedNotice(value: string | undefined): LinkedNotice | null { if (value === "failed") { return { tone: "error", - text: "That account could not be linked. Nothing was saved — try again.", + text: "Your GitHub account could not be linked. Try again.", }; } if (value === "github-taken") { return { tone: "error", - text: "That GitHub account is already linked to another OpenBot user. Disconnect it there first, or connect a different GitHub account.", + text: "That GitHub account is already linked to another OpenBot user. Sign in to GitHub as a different account and connect again, or ask your OpenBot administrator for help.", }; } return null; From bff39d018899ce06eb0a2144d8ea846a20f19e94 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:44:46 -0300 Subject: [PATCH 098/101] fix(server): keep account ids out of the link audit-failure log --- server/src/identity/audit-failure.ts | 33 +++++++++++++++++++ server/src/identity/github-callback.ts | 11 +------ server/src/identity/slack-redeem.ts | 11 ++----- server/tests/identity-github-callback.test.ts | 15 +++++++-- server/tests/identity-slack-redeem.test.ts | 13 ++++++-- 5 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 server/src/identity/audit-failure.ts diff --git a/server/src/identity/audit-failure.ts b/server/src/identity/audit-failure.ts new file mode 100644 index 000000000..b034e3393 --- /dev/null +++ b/server/src/identity/audit-failure.ts @@ -0,0 +1,33 @@ +type Driver = { errno?: unknown; code?: unknown }; + +function sqlState(error: unknown): string | undefined { + // Bun's driver puts the SQLSTATE in `errno`; node-style drivers use `code`. Drizzle wraps the + // driver error as `cause`. + for (const candidate of [ + error, + (error as { cause?: unknown } | null | undefined)?.cause, + ]) { + const driver = candidate as Driver | null | undefined; + for (const value of [driver?.errno, driver?.code]) + if (typeof value === "string" && /^[0-9A-Z]{5}$/.test(value)) + return value; + } + return undefined; +} + +/** + * Logs that the `identity.linked` audit write failed after the link committed. A database error's + * message carries the failed query's parameters (user id, link id, payload), so only the error's + * name and its Postgres SQLSTATE are logged: never the message, query, ids or subjects. + */ +export function logLinkAuditFailure(provider: string, error: unknown): void { + const code = sqlState(error); + console.error( + JSON.stringify({ + type: "identity-link-audit-failed", + provider, + errorName: error instanceof Error ? error.name : "unknown", + ...(code ? { errorCode: code } : {}), + }), + ); +} diff --git a/server/src/identity/github-callback.ts b/server/src/identity/github-callback.ts index a8216a16b..f83043429 100644 --- a/server/src/identity/github-callback.ts +++ b/server/src/identity/github-callback.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { type AuditStore, recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; import { type CredentialStore, encryptSecret } from "../credentials"; +import { logLinkAuditFailure } from "./audit-failure"; import { exchangeGithubCode, fetchGithubUser, @@ -28,16 +29,6 @@ export type GithubCallbackDeps = { now?: () => Date; }; -function logLinkAuditFailure(provider: string, error: unknown): void { - console.error( - JSON.stringify({ - type: "identity-link-audit-failed", - provider, - error: String(error), - }), - ); -} - function logRefused(reason: string): void { console.warn( JSON.stringify({ type: "identity-github-callback-refused", reason }), diff --git a/server/src/identity/slack-redeem.ts b/server/src/identity/slack-redeem.ts index ebad9544c..5aa6e0b53 100644 --- a/server/src/identity/slack-redeem.ts +++ b/server/src/identity/slack-redeem.ts @@ -1,4 +1,5 @@ import { type AuditStore, recordAuditEvent } from "../audit"; +import { logLinkAuditFailure } from "./audit-failure"; import { slackRealm } from "./providers"; import type { IdentityStore } from "./store"; import { @@ -54,15 +55,7 @@ export function slackCodeRedeemer( targetId: link.id, actorUserId: link.userId, payload: { actor: link.userId, provider: "slack" }, - }).catch((error) => - console.error( - JSON.stringify({ - type: "identity-link-audit-failed", - provider: "slack", - error: String(error), - }), - ), - ); + }).catch((error) => logLinkAuditFailure("slack", error)); } return "linked"; }, diff --git a/server/tests/identity-github-callback.test.ts b/server/tests/identity-github-callback.test.ts index e4334c5f3..18abb0ee6 100644 --- a/server/tests/identity-github-callback.test.ts +++ b/server/tests/identity-github-callback.test.ts @@ -302,7 +302,14 @@ describe("GitHub sign-in callback", () => { }); test("an audit write that fails after the link committed still reports linked, and logs", async () => { - const harness = build({ auditError: new Error("audit db down") }); + const harness = build({ + auditError: Object.assign( + new Error( + 'Failed query: insert into audit params: user-id-77,link-id-88 {"actor":"user-id-77"}', + ), + { name: "DrizzleQueryError", cause: { errno: "08006" } }, + ), + }); expect( await call(harness, `code=the-code&state=${await validState()}`), ).toBe(OK); @@ -313,8 +320,12 @@ describe("GitHub sign-in callback", () => { expect(JSON.parse(logged[0] as string)).toEqual({ type: "identity-link-audit-failed", provider: "github", - error: "Error: audit db down", + errorName: "DrizzleQueryError", + errorCode: "08006", }); + expect(logged[0]).not.toContain("user-id-77"); + expect(logged[0]).not.toContain("link-id-88"); + expect(logged[0]).not.toContain("Failed query"); for (const secret of [ "the-code", "gho_secret", diff --git a/server/tests/identity-slack-redeem.test.ts b/server/tests/identity-slack-redeem.test.ts index d8bd79cf2..cc8c2e714 100644 --- a/server/tests/identity-slack-redeem.test.ts +++ b/server/tests/identity-slack-redeem.test.ts @@ -126,7 +126,12 @@ describe("slackCodeRedeemer", () => { try { const failing: AuditStore = { insert: async () => { - throw new Error("audit db down"); + throw Object.assign( + new Error( + 'Failed query: insert into audit params: user-id-77,link-id-88 {"actor":"user-id-77"}', + ), + { name: "DrizzleQueryError", cause: { errno: "08006" } }, + ); }, }; expect( @@ -139,8 +144,12 @@ describe("slackCodeRedeemer", () => { expect(JSON.parse(logged[0] as string)).toEqual({ type: "identity-link-audit-failed", provider: "slack", - error: "Error: audit db down", + errorName: "DrizzleQueryError", + errorCode: "08006", }); + expect(logged[0]).not.toContain("user-id-77"); + expect(logged[0]).not.toContain("link-id-88"); + expect(logged[0]).not.toContain("Failed query"); expect(logged[0]).not.toContain("CODE-1"); expect(logged[0]).not.toContain("U1"); } finally { From 5806b294d6a4bb298f989b8a2bc771968d8701e3 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:50:02 -0300 Subject: [PATCH 099/101] fix(server): report a database error code only for Postgres errors --- server/src/identity/audit-failure.ts | 10 +-- server/tests/identity-audit-failure.test.ts | 66 +++++++++++++++++++ server/tests/identity-github-callback.test.ts | 5 +- server/tests/identity-slack-redeem.test.ts | 5 +- 4 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 server/tests/identity-audit-failure.test.ts diff --git a/server/src/identity/audit-failure.ts b/server/src/identity/audit-failure.ts index b034e3393..23e144ea4 100644 --- a/server/src/identity/audit-failure.ts +++ b/server/src/identity/audit-failure.ts @@ -1,14 +1,16 @@ -type Driver = { errno?: unknown; code?: unknown }; +type Driver = { name?: unknown; errno?: unknown; code?: unknown }; function sqlState(error: unknown): string | undefined { - // Bun's driver puts the SQLSTATE in `errno`; node-style drivers use `code`. Drizzle wraps the - // driver error as `cause`. + // Bun's driver throws a `PostgresError` with the SQLSTATE in `errno`; node-style drivers use + // `code`. Drizzle wraps the driver error as `cause`. Only a Postgres error is trusted: system + // errno names such as EPIPE or EPERM are also five characters and must not pass as a SQLSTATE. for (const candidate of [ error, (error as { cause?: unknown } | null | undefined)?.cause, ]) { const driver = candidate as Driver | null | undefined; - for (const value of [driver?.errno, driver?.code]) + if (driver?.name !== "PostgresError") continue; + for (const value of [driver.errno, driver.code]) if (typeof value === "string" && /^[0-9A-Z]{5}$/.test(value)) return value; } diff --git a/server/tests/identity-audit-failure.test.ts b/server/tests/identity-audit-failure.test.ts new file mode 100644 index 000000000..f22886e89 --- /dev/null +++ b/server/tests/identity-audit-failure.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { logLinkAuditFailure } from "../src/identity/audit-failure"; + +let spy: ReturnType; +beforeEach(() => { + spy = spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => spy.mockRestore()); + +function logged(error: unknown): Record { + logLinkAuditFailure("github", error); + return JSON.parse(spy.mock.calls[0][0] as string); +} + +function pgError(errno: string, message = "boom user-123"): Error { + const error = new Error(message); + error.name = "PostgresError"; + Object.assign(error, { errno, code: "ERR_POSTGRES_SERVER_ERROR" }); + return error; +} + +test("reports the SQLSTATE of a Postgres error", () => { + expect(logged(pgError("57014"))).toEqual({ + type: "identity-link-audit-failed", + provider: "github", + errorName: "PostgresError", + errorCode: "57014", + }); +}); + +test("reports the SQLSTATE of a Postgres error wrapped as a cause", () => { + const wrapper = new Error("Failed query: insert ... params: user-123", { + cause: pgError("23505"), + }); + expect(logged(wrapper).errorCode).toBe("23505"); +}); + +test("does not report system error codes as a SQLSTATE", () => { + for (const code of ["EPIPE", "EPERM", "ECONNRESET"]) { + spy.mockClear(); + const error = Object.assign(new Error("sys"), { code, errno: code }); + expect(logged(error)).not.toHaveProperty("errorCode"); + spy.mockClear(); + expect(logged(new Error("w", { cause: error }))).not.toHaveProperty( + "errorCode", + ); + } +}); + +test("omits errorCode when there is none", () => { + expect(logged(new Error("x"))).not.toHaveProperty("errorCode"); +}); + +test("handles non-Error thrown values", () => { + for (const value of ["str", null, undefined, 42, { errno: "23505" }]) { + spy.mockClear(); + const out = logged(value); + expect(out.errorName).toBe("unknown"); + expect(out).not.toHaveProperty("errorCode"); + } +}); + +test("never logs the message or ids", () => { + logLinkAuditFailure("slack", pgError("23505", "secret user-123")); + expect(spy.mock.calls[0][0] as string).not.toContain("user-123"); +}); diff --git a/server/tests/identity-github-callback.test.ts b/server/tests/identity-github-callback.test.ts index 18abb0ee6..58514ac55 100644 --- a/server/tests/identity-github-callback.test.ts +++ b/server/tests/identity-github-callback.test.ts @@ -307,7 +307,10 @@ describe("GitHub sign-in callback", () => { new Error( 'Failed query: insert into audit params: user-id-77,link-id-88 {"actor":"user-id-77"}', ), - { name: "DrizzleQueryError", cause: { errno: "08006" } }, + { + name: "DrizzleQueryError", + cause: { name: "PostgresError", errno: "08006" }, + }, ), }); expect( diff --git a/server/tests/identity-slack-redeem.test.ts b/server/tests/identity-slack-redeem.test.ts index cc8c2e714..f99a212b1 100644 --- a/server/tests/identity-slack-redeem.test.ts +++ b/server/tests/identity-slack-redeem.test.ts @@ -130,7 +130,10 @@ describe("slackCodeRedeemer", () => { new Error( 'Failed query: insert into audit params: user-id-77,link-id-88 {"actor":"user-id-77"}', ), - { name: "DrizzleQueryError", cause: { errno: "08006" } }, + { + name: "DrizzleQueryError", + cause: { name: "PostgresError", errno: "08006" }, + }, ); }, }; From 78b11af52f21916c15be990cd8efc1dda74a65f5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 8 Oct 2026 18:50:13 -0300 Subject: [PATCH 100/101] fix(server): cancel a Slack link code pasted in a channel without a mention --- server/src/delivery/opentag-agent.ts | 44 +++++++++++++++--- server/tests/delivery-routes.test.ts | 67 ++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 298539a07..27bd51e46 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -98,7 +98,8 @@ export type OpenTagAgentDeps = { * Reachability code falls through too, matches no account code, and gets the usual expired reply). * An account code is redeemed only in a direct message. One posted in a shared conversation is * cancelled (`discard`), since anyone there could send it first and bind their own Slack account - * to its issuer; the issuer is told to make a new one. + * to its issuer: with a mention the issuer is told to make a new one; without one (an observed + * post) it is cancelled quietly and redacted before the Slack triggers see the message. */ identity?: { redeem( @@ -112,6 +113,37 @@ export type OpenTagAgentDeps = { const LINK = /\blink\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i; +const LINKS = new RegExp(LINK.source, "gi"); + +/** + * A channel post the app was not mentioned in: everyone there has seen any account code in it, so + * each one is cancelled, quietly (an observe run never replies). The codes are redacted rather than + * the message dropped, so the post still fires the owners' message triggers without carrying a + * code. A failed cancel is logged and never stops the triggers. + */ +async function withoutLinkCodes( + deps: OpenTagAgentDeps, + sender: { transport: ChatTransport }, + text: string, +) { + const codes = [...text.matchAll(LINKS)].map((m) => m[1]?.toLowerCase()); + if (!codes.length) return text; + if (sender.transport === "slack" && deps.identity) + for (const code of codes) { + if (!code) continue; + try { + await deps.identity.discard(code); + } catch (error) { + console.error( + JSON.stringify({ + type: "opentag-link-code-discard-error", + errorType: error instanceof Error ? error.name : "UnknownError", + }), + ); + } + } + return text.replace(LINKS, "link [code removed]"); +} const platformName = (transport: ChatTransport) => transport === "teams" ? "Microsoft Teams" : "Slack"; @@ -230,12 +262,11 @@ async function answer( }; // Something to watch, not to answer: feed the owner's Slack triggers and stay quiet. if (sender.observe) { + const text = + sender.observe === "message" ? latestUserMessage(input)?.text : undefined; await feedSlackTriggers(deps, sender, { type: sender.observe, - text: - sender.observe === "message" - ? latestUserMessage(input)?.text - : undefined, + text: text && (await withoutLinkCodes(deps, sender, text)), }); return { text: "" }; } @@ -380,7 +411,8 @@ async function link( if (!challenge && sender.transport === "slack" && deps.identity) { // Anyone else in a shared conversation has now seen the code and could send it first, binding // their own Slack account to its issuer, so an account code is only redeemed in a direct - // message and one posted here is cancelled. + // message and one posted here is cancelled (an unmentioned channel post is cancelled in + // `withoutLinkCodes`). if (!sender.private) { if (await deps.identity.discard(code)) return { diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index b3818b9f5..ef331e39a 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -332,6 +332,73 @@ test("a Reachability code still links from a shared conversation", async () => { expect(redeemed).toEqual([]); expect(discarded).toEqual([]); }); +const observedMessage = (content: string) => ({ + context: [ + { + description: "openbot.sender", + value: JSON.stringify({ + user: "slack:T1:U1", + event: "Ev50", + conversation: { id: "C0123456789", kind: "channel" }, + observe: "message", + }), + }, + ], + messages: [{ id: "m1", role: "user", content }], +}); +const triggersOf = (f: ReturnType) => + f.calls + .map((c) => (c as { trigger?: Record }).trigger) + .filter(Boolean); +test("an identity code pasted in a channel without a mention is cancelled quietly and kept out of triggers", async () => { + for (const content of [ + `link ${IDENTITY_CODE.toUpperCase()}`, + `here: link ${IDENTITY_CODE.toUpperCase()} thanks`, + ]) { + const { f, redeemed, discarded } = identityFixture("linked"); + const result = await run(f, observedMessage(content)); + expect(said(result.events)).toBe(""); + expect(discarded).toEqual([IDENTITY_CODE]); + expect(redeemed).toEqual([]); + const triggers = triggersOf(f); + expect(triggers).toHaveLength(1); + expect(triggers[0]).toMatchObject({ type: "message", eventId: "Ev50" }); + expect(JSON.stringify(triggers[0]).toLowerCase()).not.toContain( + IDENTITY_CODE, + ); + } +}); +test("an ordinary observed channel message reaches triggers unchanged and discards nothing", async () => { + const { f, discarded } = identityFixture("linked"); + const result = await run(f, observedMessage("deploy is broken")); + expect(said(result.events)).toBe(""); + expect(discarded).toEqual([]); + expect(triggersOf(f)).toEqual([ + expect.objectContaining({ type: "message", text: "deploy is broken" }), + ]); +}); +test("a failed discard of an observed channel code still feeds triggers quietly", async () => { + const f = fixture({ + bound: false, + identity: { + redeem: async () => "invalid", + discard: async () => { + throw new Error("db down"); + }, + }, + }); + const error = console.error; + console.error = () => {}; + try { + const result = await run(f, observedMessage(`link ${IDENTITY_CODE}`)); + expect(said(result.events)).toBe(""); + } finally { + console.error = error; + } + const triggers = triggersOf(f); + expect(triggers).toHaveLength(1); + expect(JSON.stringify(triggers[0])).not.toContain(IDENTITY_CODE); +}); test("an identity code conflict gets the already-linked reply", async () => { const { f } = identityFixture("conflict"); const result = await run(f, { From 4ef0c723709abf593d272f5c0f42004f270b7016 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Fri, 9 Oct 2026 09:15:22 -0300 Subject: [PATCH 101/101] docs: note linked Slack and GitHub accounts in the changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5d2bfe4..77a691b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. such record for up to ten minutes after the upgrade, and calls they make to a Shared app in that window are refused. +### People can link their Slack and GitHub accounts + +Settings → Connected accounts gains a Linked accounts section with one card per account a person can +link. Slack links through a one-time code sent to the OpenTag app in a direct message; a code posted +in a channel is cancelled. GitHub links through a GitHub App's user sign-in, set with +`GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET` (both or neither); the server refuses to start +with them and no public URL. Nothing reads the links yet: they are what GitHub alerts and reviews will +build on. Migration `0053_identity_links` only adds tables. + ### An app's account can belong to the team An administrator can make a Composio app Shared: one account, connected once, that every Bot granted