diff --git a/.env.example b/.env.example index 819fadd2b..821f65b2e 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 @@ -436,6 +437,20 @@ 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, 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= + # --------------------------------------------------------------------------- # Reaching people outside the app: Slack, Teams, SMS, push # --------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 593f2c24d..e058914fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,15 @@ Settings now also holds whether your Bots ask before acting, rules for every Bot devices and recent deliveries, and GitHub event sources. Team approval settings and shared account requests moved to Admin → Approvals. +### 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 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..a81f6f775 --- /dev/null +++ b/app/src/components/settings/linked-accounts.test.tsx @@ -0,0 +1,349 @@ +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 { + connectGithubPending, + copyText, + linkedAccountDescription, + linkedAccountsSectionState, + providerIcon, +} 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"); +}); + +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("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( + { + 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"); +}); + +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 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 }; +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 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("each account type that can be linked gets its own card to connect from", () => { + expect( + linkedAccountsSectionState({ + data: [], + error: null, + providers: { slack: true, github: true }, + }), + ).toEqual({ + entries: [ + { kind: "available", provider: "slack", title: "Slack" }, + { kind: "available", provider: "github", title: "GitHub" }, + ], + error: null, + providersError: null, + }); + expect( + linkedAccountsSectionState({ + data: [], + error: null, + providers: { slack: false, github: true }, + }), + ).toEqual({ + entries: [{ kind: "available", provider: "github", title: "GitHub" }], + error: null, + providersError: null, + }); +}); + +test("a linked account takes the place of its connect card", () => { + expect( + linkedAccountsSectionState({ + data: [row], + error: null, + providers: { slack: true, github: true }, + }), + ).toEqual({ + entries: [ + { kind: "available", provider: "slack", title: "Slack" }, + { kind: "linked", account: row }, + ], + error: null, + providersError: null, + }); +}); + +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, + providersError: null, + }); +}); + +test("a failed refetch keeps stale rows on screen and shows the error with them", () => { + expect( + linkedAccountsSectionState({ + data: [row], + error: new Error("boom"), + providers: none, + }), + ).toEqual({ + entries: [{ kind: "linked", account: row }], + error: "boom", + providersError: null, + }); +}); + +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"), + providers: none, + }), + ).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 })), + ).resolves.toBeUndefined(); +}); + +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.", 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 })), + ).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"); +}); + +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); +}); + +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 new file mode 100644 index 000000000..eb9437467 --- /dev/null +++ b/app/src/components/settings/linked-accounts.tsx @@ -0,0 +1,378 @@ +import { IconBrandGithub, IconBrandSlack, IconLink } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { 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, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { + connectGithubMutationOptions, + issueSlackCodeMutationOptions, + type SlackLinkCode, + unlinkMutationOptions, +} from "@/lib/identity/mutations"; +import { + type IdentityProviders, + identityKeys, + identityProvidersQueryOptions, + type LinkedAccount, + linkedAccountsQueryOptions, +} from "@/lib/identity/queries"; +import { queryClient } from "@/query-client"; + +export function linkedAccountDescription(account: LinkedAccount): string { + 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) { + // 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; +} + +/** 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: one card per account type, then an error if the list failed. + * + * 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. 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; + providersError?: Error | null; +}): { + entries: LinkedAccountsEntry[]; + error: string | null; + providersError: 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 }); + } + if (entries.length === 0 && !error && !providersError) return null; + return { + entries, + error: error ? error.message : null, + providersError: providersError ? providersError.message : null, + }; +} + +/** + * 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. + * + * 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 providers = useQuery(identityProvidersQueryOptions()); + const issueCode = useMutation(issueSlackCodeMutationOptions()); + const connectGithub = useMutation(connectGithubMutationOptions()); + 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); + setCopyFailed(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, + providers: providers.data, + providersError: providers.error, + }); + if (!state) return null; + const connect = { + slack: { + pending: issueCode.isPending, + start: () => + issueCode.mutate(undefined, { onSuccess: (code) => setIssued(code) }), + }, + github: { + pending: connectGithubPending(connectGithub), + start: () => + connectGithub.mutate(undefined, { + onSuccess: (url) => window.location.assign(url), + }), + }, + }; + return ( + + {state.entries.length > 0 ? ( + + {state.entries.map((entry, index) => ( +
+ {index > 0 ? : null} + {entry.kind === "linked" ? ( + + ) : ( + + )} +
+ ))} +
+ ) : null} + {state.error ? ( +

+ 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} +

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

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

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

{issued.instruction}

+
+ + link {issued.code} + + +
+ {copyFailed ? ( +

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

+ ) : null} +

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

+
+ ) : null} +
+ + + +
+
+
+ ); +} + +/** 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. + */ +function LinkedAccountRow({ account }: { account: LinkedAccount }) { + const unlink = useMutation(unlinkMutationOptions(queryClient)); + const Icon = providerIcon(account.provider); + return ( + <> + + + + + + {account.title} + {linkedAccountDescription(account)} + + + + + + {unlink.error ? ( +

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

+ ) : null} + + ); +} + +/** + * 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; + } +} 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..63ad78076 --- /dev/null +++ b/app/src/components/settings/linked-notice.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { linkedNotice, visibleLinkedNotice } 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: "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. Sign in to GitHub as a different account and connect again, or ask your OpenBot administrator for help.", + }); + }); + + test.each([undefined, "", "slack", "__proto__"])( + "says nothing for %p", + (value) => { + expect(linkedNotice(value)).toBeNull(); + }, + ); +}); + +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 new file mode 100644 index 000000000..6063e99ad --- /dev/null +++ b/app/src/components/settings/linked-notice.ts @@ -0,0 +1,41 @@ +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 three 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: "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. Sign in to GitHub as a different account and connect again, or ask your OpenBot administrator for help.", + }; + } + 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/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 new file mode 100644 index 000000000..b2086634f --- /dev/null +++ b/app/src/lib/identity/mutations.ts @@ -0,0 +1,102 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client, tryClient } from "@/lib/client"; +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"; + +/** + * 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) 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) { + return mutationOptions({ + mutationFn: async (id: string): Promise => + unlinkOutcome( + await tryClient(`/api/identity/links/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + ), + onSettled: () => + 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", + }), + }); +} + +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 => + // `client` yields undefined when the envelope key is absent; the validator handles that. + githubAuthorizationUrl( + await client("/api/identity/github/connect", "connect", { + method: "POST", + fallback: CONNECT_GITHUB_FAILED, + }), + ), + }); +} 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 new file mode 100644 index 000000000..3d20e3b99 --- /dev/null +++ b/app/src/lib/identity/queries.ts @@ -0,0 +1,100 @@ +import { queryOptions } from "@tanstack/react-query"; +import { tryClient } from "@/lib/client"; + +export type LinkedAccount = { + id: string; + provider: "slack" | "github"; + title: string; + handle: string | null; + status: "active" | "needs_reconnect"; + createdAt: string; +}; + +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"; + +/** 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 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.ok) { + 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[]; + } | 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 => + 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")), + }); +} diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx index dd7736c40..9eaeea4d2 100644 --- a/app/src/routes/_authed/settings/connected-accounts/index.tsx +++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx @@ -16,6 +16,11 @@ 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 { + linkedNotice, + visibleLinkedNotice, +} from "@/components/settings/linked-notice"; import { GithubSources } from "@/components/settings/github-sources"; import { Button } from "@/components/ui/button"; import { @@ -30,6 +35,7 @@ import { ItemDescription, ItemTitle, } from "@/components/ui/item"; +import { linkedAccountsQueryOptions } from "@/lib/identity/queries"; import { connectionsQueryOptions, type PluginServer, @@ -50,14 +56,22 @@ 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, `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. */ - 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. */ @@ -125,7 +139,18 @@ export function connectedAccountSections( } 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 [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) { + navigate({ search: ({ linked: _l, ...rest }) => rest, replace: true }); + } + }, [linked, navigate]); const [search, setSearch] = React.useState(""); const plugins = useQuery(pluginsPageQueryOptions()); const connections = useQuery(connectionsQueryOptions()); @@ -226,14 +251,26 @@ 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" ? (

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 @@ -389,6 +426,7 @@ function RouteComponent() { ) : null} + diff --git a/docs/configuration.md b/docs/configuration.md index c914468f7..5c6ab519b 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 @@ -485,6 +486,58 @@ 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 + +OpenBot keeps a record of a person's accounts at outside providers, such as their GitHub account +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 +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. + +### Linking Slack and GitHub accounts + +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`. 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** 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 +account; a `link ` from **Linked accounts** links the Slack user. + ## Text messages and push notifications A provider is switched on by setting its variables and is off while none of them are set. Setting diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 8298c48b4..b78287182 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ "./src/db/schema/passwords.ts", "./src/db/schema/team-bots.ts", "./src/db/schema/shared-use.ts", + "./src/db/schema/identity.ts", ], out: "./drizzle", dbCredentials: { diff --git a/server/drizzle/0053_identity_links.sql b/server/drizzle/0053_identity_links.sql new file mode 100644 index 000000000..95b1fc4a4 --- /dev/null +++ b/server/drizzle/0053_identity_links.sql @@ -0,0 +1,29 @@ +CREATE TABLE "identity_link_challenges" ( + "token_hash" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "user_id" text NOT NULL, + "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_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/0053_snapshot.json b/server/drizzle/meta/0053_snapshot.json new file mode 100644 index 000000000..50a7afe16 --- /dev/null +++ b/server/drizzle/meta/0053_snapshot.json @@ -0,0 +1,9743 @@ +{ + "id": "a0509e82-296a-4370-8932-1d19b9c2b426", + "prevId": "e7a6567d-808f-491c-b2cc-487aa287735c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 068d23ae8..7b3bedd10 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1791396757748, "tag": "0052_shared_brokered_accounts", "breakpoints": true + }, + { + "idx": 53, + "version": "7", + "when": 1791547580808, + "tag": "0053_identity_links", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/app.ts b/server/src/app.ts index bd387f00f..f09acf6a7 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, @@ -435,6 +437,10 @@ 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, + /** 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); @@ -691,6 +697,41 @@ 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 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); + 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. 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 && + coworker.delivery.webhooks.identity, + ), + github: + identityGithubCallback && 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) { return context.json( diff --git a/server/src/audit.ts b/server/src/audit.ts index 49cf3ac82..3b59bcabb 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -520,6 +520,24 @@ 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. + */ + "identity.unlinked", + /* + * 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. */ "routine.created", "routine.updated", diff --git a/server/src/config.ts b/server/src/config.ts index bb557c663..2549bd953 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -90,6 +90,11 @@ 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; +}; + export type AuthConfig = { baseUrl: string; secret: string; @@ -217,6 +222,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 +671,23 @@ 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"); + + // 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 } : undefined; +} + function commaSeparated(environment: Environment, name: string): string[] { return (optional(environment, name) ?? "") .split(",") @@ -1269,6 +1293,17 @@ export function loadConfig( ? organizationAuthority(organizationAuthValue) : 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 { @@ -1278,12 +1313,11 @@ 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"), - 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/src/db/schema/identity.ts b/server/src/db/schema/identity.ts new file mode 100644 index 000000000..952d4626f --- /dev/null +++ b/server/src/db/schema/identity.ts @@ -0,0 +1,90 @@ +/** + * 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, + ), + // 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')`, + ), + check( + "identity_links_status_check", + sql`${table.status} IN ('active', 'needs_reconnect')`, + ), + ], +); + +/** + * 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(), + userId: text("user_id").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (table) => [ + index("identity_link_challenges_expiry_idx").on(table.expiresAt), + // 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/db/schema/index.ts b/server/src/db/schema/index.ts index c34f0c614..287b334f2 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/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 544a86c9f..f00f55d85 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -92,10 +92,58 @@ export type OpenTagAgentDeps = { approvals?: PairedApprovals; /** Slack trigger ingest; defaults to the responsibilities lane's `ingestSlackEvent`. */ ingestSlack?: (event: SlackTriggerEvent) => Promise; + /** + * 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: 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( + 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; + }; }; 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"; @@ -214,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: "" }; } @@ -352,10 +399,42 @@ 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 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 (an unmentioned channel post is cancelled in + // `withoutLinkCodes`). + 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: "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, + }); + 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..15a11b5ab 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 in a DM; cancels one posted in a channel. */ + 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/src/identity/audit-failure.ts b/server/src/identity/audit-failure.ts new file mode 100644 index 000000000..23e144ea4 --- /dev/null +++ b/server/src/identity/audit-failure.ts @@ -0,0 +1,35 @@ +type Driver = { name?: unknown; errno?: unknown; code?: unknown }; + +function sqlState(error: unknown): string | undefined { + // 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; + 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; + } + 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 new file mode 100644 index 000000000..f83043429 --- /dev/null +++ b/server/src/identity/github-callback.ts @@ -0,0 +1,183 @@ +import { randomUUID } from "node:crypto"; +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, + GithubOAuthError, + githubRedirectUri, + openGithubState, +} from "./github-oauth"; +import { GITHUB_DOTCOM_REALM } from "./providers"; +import type { IdentityStore } from "./store"; +import { IdentityConflictError } from "./types"; + +export type GithubCallbackDeps = { + clientId: string; + clientSecret: string; + publicUrl: string; + appUrl: string | undefined; + encryptionKey: string; + personIsActive(userId: string): Promise; + credentials: Pick; + identity: Pick; + auditStore?: AuditStore; + fetchImpl?: typeof fetch; + now?: () => Date; +}; + +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 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. + */ +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); + let stage = "state"; + try { + const code = context.req.query("code"); + 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) { + 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) { + logRefused("session-mismatch"); + return failed(); + } + + stage = "person"; + if (!(await deps.personIsActive(state.userId))) { + logRefused("inactive-person"); + return failed(); + } + + stage = "exchange"; + const tokens = await exchangeGithubCode( + { + clientId: deps.clientId, + clientSecret: deps.clientSecret, + code, + redirectUri: githubRedirectUri(deps.publicUrl), + }, + 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 + // 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. + stage = "store"; + const stored = await deps.credentials.create({ + kind: "connector", + provider: "github-user-token", + keyId: `${state.userId}:${user.id}:${randomUUID()}`, + metadata: { login: user.login }, + encryptedValue: await encryptSecret( + deps.encryptionKey, + JSON.stringify(tokens), + ), + }); + + stage = "link"; + 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) { + // Only the token this attempt stored; the existing link's token was never touched. + try { + await deps.credentials.revoke(stored.id); + } catch (revokeError) { + // 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; + } + + // 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", + targetType: "identity_link", + 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) { + logFailed(stage, error); + return failed(); + } + }); + + return routes; +} 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/src/identity/providers.ts b/server/src/identity/providers.ts new file mode 100644 index 000000000..044b29499 --- /dev/null +++ b/server/src/identity/providers.ts @@ -0,0 +1,87 @@ +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 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> = + Object.freeze({ + slack: { + title: "Slack", + methods: ["challenge"], + instruction: (code) => + `Send this message to the OpenBot app in a direct message in Slack: link ${code}`, + }, + github: { + title: "GitHub", + methods: ["oauth"], + credentialProvider: "github-user-token", + }, + }); + +const REALM_MAX = 512; + +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); +} + +/** + * 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; + installationId: string; + workspaceId: string; +}): string { + const values = [parts.connectionId, parts.installationId, parts.workspaceId]; + if (values.some((value) => !value.trim() || value !== value.trim())) + throw new IdentityInputError("Slack identity is incomplete."); + 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().max(REALM_MAX).refine(nonBlank), + subject: z.string().max(256).refine(nonBlank), +}); + +/** 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/src/identity/retire.ts b/server/src/identity/retire.ts new file mode 100644 index 000000000..b43c3c01d --- /dev/null +++ b/server/src/identity/retire.ts @@ -0,0 +1,173 @@ +import { asc, eq } from "drizzle-orm"; +import { type AuditStore, recordAuditEvent } from "../audit"; +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. + * + * 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. + * + * 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, 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 (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 + * 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. + */ +export async function retireIdentityLinks( + database: Database, + credentials: Pick, + auditFor: (executor: Database) => AuditStore, + userId: string, + by: string, +): Promise { + return database.transaction(async (transaction) => { + const auditStore = auditFor(transaction as unknown as Database); + await identityUserLock(transaction, userId); + 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) + await identityRealmLock(transaction, userId, provider, realm); + + 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 changed = 0; + for (const link of links) { + if (!link.credentialId && link.status === "needs_reconnect") continue; + const credentialRevoked = await revokeIfLive( + credentials, + link.credentialId, + transaction, + ); + await transaction + .update(identityLinks) + .set({ + credentialId: null, + status: "needs_reconnect", + updatedAt: new Date(), + }) + .where(eq(identityLinks.id, link.id)); + await recordAuditEvent(auditStore, { + eventType: "identity.link_retired", + targetType: "identity_link", + targetId: link.id, + payload: { + actor: by, + owner: userId, + provider: link.provider, + reason: "person_removed", + credentialRevoked, + }, + }); + changed += 1; + } + return changed; + }); +} + +/** + * 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 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 }>; + 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 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/identity/routes.ts b/server/src/identity/routes.ts new file mode 100644 index 000000000..4d53c0af7 --- /dev/null +++ b/server/src/identity/routes.ts @@ -0,0 +1,164 @@ +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"; + +export type IdentityRouteOptions = { + slackLinking?: boolean; + github?: { clientId: string; publicUrl: string; encryptionKey: string }; +}; + +/** + * 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 + * draws; realm, subject and credential stay on the server. + */ +export function identityRoutes( + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + store?: Pick, + auditStore?: AuditStore, + options: IdentityRouteOptions = {}, +) { + const app = new Hono<{ Variables: AppVariables }>(); + app.use("*", async (context, next) => { + context.header("cache-control", "no-store"); + await next(); + }); + 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.", + code: "identity_unavailable", + }, + 503, + ), + ); + return app; + } + const live = store; + + app.get("/providers", (context) => + context.json({ + providers: { + slack: Boolean(options.slackLinking), + github: Boolean(options.github), + }, + }), + ); + + 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.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({ + 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 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.", 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. + * 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. + */ + if (auditStore) { + await recordAuditEvent(auditStore, { + eventType: "identity.unlinked", + targetType: "identity_link", + targetId: id, + actorUserId: actorId, + payload: { actor: actorId, provider: removed.provider }, + }); + } + return context.body(null, 204); + }); + + return app; +} diff --git a/server/src/identity/slack-redeem.ts b/server/src/identity/slack-redeem.ts new file mode 100644 index 000000000..5aa6e0b53 --- /dev/null +++ b/server/src/identity/slack-redeem.ts @@ -0,0 +1,63 @@ +import { type AuditStore, recordAuditEvent } from "../audit"; +import { logLinkAuditFailure } from "./audit-failure"; +import { slackRealm } from "./providers"; +import type { IdentityStore } from "./store"; +import { + IdentityConflictError, + IdentityInputError, + IdentityLinkError, +} from "./types"; + +/** + * 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, + 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 { + 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" }, + }).catch((error) => logLinkAuditFailure("slack", error)); + } + return "linked"; + }, + }; +} diff --git a/server/src/identity/store.ts b/server/src/identity/store.ts new file mode 100644 index 000000000..215f40613 --- /dev/null +++ b/server/src/identity/store.ts @@ -0,0 +1,605 @@ +import { createHash, randomUUID } from "node:crypto"; +import { and, eq, gt, isNull, lt, notInArray, sql } from "drizzle-orm"; +import { CredentialRefusedError, type CredentialStore } from "../credentials"; +import type { Database } from "../db/client"; +import { + credentials as credentialRows, + identityLinkChallenges, + identityLinks, +} from "../db/schema"; +import { + acceptsMethod, + isIdentityProvider, + PROVIDERS, + parseIdentity, +} from "./providers"; +import { + type Identity, + IdentityConflictError, + IdentityInputError, + type IdentityLink, + IdentityLinkError, + type IdentityProvider, + type LinkMethod, + type LinkStatus, +} from "./types"; + +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( + eq(identityLinks.provider, identity.provider), + eq(identityLinks.realm, identity.realm), + eq(identityLinks.subject, identity.subject), + ); + +/** `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, + 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, + }; +} + +/** + * 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( + 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; + } +} + +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(""); +} + +/** 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."; + +/** + * 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), 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 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( + 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) + .where( + and( + eq(credentialRows.id, credentialId), + isNull(credentialRows.revokedAt), + eq(credentialRows.kind, "connector"), + eq(credentialRows.provider, credentialProvider), + ), + ) + .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 + * 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 + * (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. + * + * 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. + */ +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, + userId: string, + proof: { + method: LinkMethod; + handle?: string | null; + credentialId?: string; + }, +): Promise { + await identityRealmLock( + transaction, + userId, + identity.provider, + identity.realm, + ); + 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 (proof.credentialId) + 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); + const [updated] = await transaction + .update(identityLinks) + .set({ + handle: proof.handle === undefined ? existing.handle : proof.handle, + verifiedBy: proof.method, + credentialId, + status: "active", + updatedAt: now, + }) + .where(eq(identityLinks.id, existing.id)) + .returning(); + return toLink( + updated as typeof identityLinks.$inferSelect, + identity.provider, + ); + } + 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 (proof.credentialId) + await claimCredential( + transaction, + identity.provider, + proof.credentialId, + previous ? [previous.id] : [], + ); + if (previous) { + await transaction + .delete(identityLinks) + .where(eq(identityLinks.id, previous.id)); + // 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, + ); +} + +/** 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) { + 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> { + 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); + // `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; + }, + + /** + * Link `value` to `userId` on proof the caller has already verified. + * + * 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. + * + * 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, + proof: { + method: LinkMethod; + handle?: string | null; + credentialId?: string; + }, + ): Promise { + const identity = parseIdentity(value); + requireUserId(userId); + if (!acceptsMethod(identity.provider, proof.method)) + throw new IdentityInputError( + "This provider does not accept that kind of proof.", + ); + if (proof.credentialId != null && proof.method === "challenge") + throw new IdentityInputError( + "A challenge link does not carry a credential.", + ); + // 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; + const handle = normalizeHandle(proof.handle); + return database.transaction((transaction) => + writeLink(transaction, credentials, identity, userId, { + method: proof.method, + handle, + credentialId, + }), + ); + }, + + async markNeedsReconnect(linkId: string): Promise { + await database + .update(identityLinks) + .set({ status: "needs_reconnect", updatedAt: new Date() }) + .where(eq(identityLinks.id, linkId)); + }, + + /** 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, + provider: identityLinks.provider, + }); + if (!removed) return null; + await revokeQuietly(credentials, removed.credentialId, transaction); + return { provider: removed.provider }; + }); + }, + + /** + * 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, + provider: IdentityProvider, + ): Promise<{ code: string; expiresAt: Date }> { + requireUserId(userId); + if ( + !isIdentityProvider(provider) || + !acceptsMethod(provider, "challenge") + ) + throw new IdentityInputError( + "This provider does not accept that kind of proof.", + ); + const code = randomUUID(); + return database.transaction(async (transaction) => { + await identityUserLock(transaction, userId); + // 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( + 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'`, + }) + // 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 }); + // 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 }; + }); + }, + + /** + * 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. + * `handle` follows the same rule as linkVerified: omitted keeps the stored one, null clears. + */ + async redeemChallenge( + code: string, + value: Identity, + 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() + .from(identityLinkChallenges) + .where(eq(identityLinkChallenges.tokenHash, hash)) + .for("update"); + 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 + .delete(identityLinkChallenges) + .where( + and( + eq(identityLinkChallenges.tokenHash, hash), + gt(identityLinkChallenges.expiresAt, sql`clock_timestamp()`), + ), + ) + .returning({ userId: identityLinkChallenges.userId }); + if (!consumed) throw new IdentityLinkError(); + return writeLink(transaction, credentials, identity, consumed.userId, { + method: "challenge", + handle: normalized, + }); + }); + }, + }; +} + +export type IdentityStore = ReturnType; diff --git a/server/src/identity/types.ts b/server/src/identity/types.ts new file mode 100644 index 000000000..a91b3a5b2 --- /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 code is invalid, expired, or already used.") { + 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"; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index d038b09cd..81e6611b5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -177,6 +177,10 @@ 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"; import { createIntelligenceClient, observeIntelligenceAuthentication, @@ -344,6 +348,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, @@ -397,14 +402,29 @@ 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. + * 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) => pluginStore.retireConnectionsFor(userId, by), + (userId, by) => + retireOwnedAccounts({ + plugins: () => pluginStore.retireConnectionsFor(userId, by), + identities: () => + retireIdentityLinks( + database, + credentialStore, + (executor) => createAuditStore(executor), + userId, + by, + ), + }), ); const identityProviderStore = createIdentityProviderStore(database); /* @@ -2868,6 +2888,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, @@ -3023,6 +3061,7 @@ const app = createApp( scopeFor: deliveryScopeFor, slack: deliveryProviders.slack, twilio: deliveryProviders.sms, + identity: slackCodeRedeemer(identityStore, bootAuditStore), }, }, memory: { store: personalMemoryStore, ingestion: memoryIngestion }, @@ -3052,6 +3091,9 @@ const app = createApp( enabled: config.selfHostBanner, entitlements: () => selfHostBannerIntelligence.getRuntimeEntitlements(), }), + + identityStore, + identityGithubCallback, ); /** diff --git a/server/tests/config-github-app.test.ts b/server/tests/config-github-app.test.ts new file mode 100644 index 000000000..a7b810bd6 --- /dev/null +++ b/server/tests/config-github-app.test.ts @@ -0,0 +1,94 @@ +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", +}; + +/** 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(); + }); + + 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("ignores GITHUB_APP_SLUG: the config carries only the pair", () => { + 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", + }); + expect(config.githubApp).not.toHaveProperty("slug"); + }); + + 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", + ); + }); + + 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"); + }); +}); diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index 677ac5650..ef331e39a 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -20,7 +20,20 @@ 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">; + discard(code: string): Promise; +}; +function fixture( + options: { + converse?: ConverseResult; + bound?: boolean; + identity?: IdentityAdapter; + } = {}, +) { const calls: unknown[] = []; const decisions: unknown[] = []; const twilio = createTwilioTransport({ @@ -97,6 +110,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 +237,203 @@ 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", + live = true, +) { + const redeemed: unknown[] = []; + const discarded: string[] = []; + const f = fixture({ + bound: false, + ...(outcome + ? { + identity: { + redeem: async (code, who) => { + redeemed.push([code, who]); + return outcome; + }, + discard: async (code) => { + discarded.push(code); + return live; + }, + }, + } + : {}), + }); + return { f, redeemed, discarded }; +} +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" }]]); +}); +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, discarded } = identityFixture("linked"); + const result = await run(f, { + context: [sender("slack:T1:U1", conversation)], + ...linkMessage(IDENTITY_CODE), + }); + 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, 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([]); +}); +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, { + 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: { diff --git a/server/tests/identity-app-wiring.test.ts b/server/tests/identity-app-wiring.test.ts new file mode 100644 index 000000000..29cc10029 --- /dev/null +++ b/server/tests/identity-app-wiring.test.ts @@ -0,0 +1,265 @@ +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 = { + 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; + githubCallback?: 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, + 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, + }).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, { + 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 }, + }); +}); + +test("connecting GitHub redirects back to this deployment's callback", async () => { + 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 }; + }; + const url = new URL(body.connect.authorizationUrl); + expect(url.searchParams.get("redirect_uri")).toBe( + "https://api.test/api/identity/github/callback", + ); +}); + +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( + "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); +}); + +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(); +}); + +/** 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-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-challenges.integration.test.ts b/server/tests/identity-challenges.integration.test.ts new file mode 100644 index 000000000..ea25c54f3 --- /dev/null +++ b/server/tests/identity-challenges.integration.test.ts @@ -0,0 +1,371 @@ +import { afterAll, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +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, identityUserLock } 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 () => { + 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("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(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 issue a code", async () => { + await expect(store.issueChallenge(user(), "github")).rejects.toThrow( + IdentityInputError, + ); + await expect( + store.issueChallenge(user(), "myspace" as IdentityProvider), + ).rejects.toThrow(IdentityInputError); + await expect(store.issueChallenge(" ", "slack")).rejects.toThrow( + IdentityInputError, + ); +}); + +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 { 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)).toEqual({ + userId: owner, + status: "active", + }); + expect(await rowFor(code)).toBeUndefined(); +}); + +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("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 code is refused", async () => { + const { code } = await store.issueChallenge(user(), "slack"); + await expire(code); + const sender = slack(); + await expect(store.redeemChallenge(code, sender)).rejects.toThrow( + IdentityLinkError, + ); + expect(await store.linkedUser(sender)).toBeNull(); +}); + +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("an unknown but well-formed code is refused", async () => { + await expect(store.redeemChallenge(randomUUID(), slack())).rejects.toThrow( + IdentityLinkError, + ); +}); + +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.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("redeeming a second code in the same realm replaces the person's older link", async () => { + const owner = user(); + const first = slack(); + const second = slack(); + 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(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); +}); + +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(); +}); + +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(); +}); + +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); + }, +); + +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-github-callback.integration.test.ts b/server/tests/identity-github-callback.integration.test.ts new file mode 100644 index 000000000..2383fcdd7 --- /dev/null +++ b/server/tests/identity-github-callback.integration.test.ts @@ -0,0 +1,283 @@ +import { afterAll, expect, test } from "bun:test"; +import { randomInt, randomUUID } from "node:crypto"; +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"; +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 TAKEN = + "https://app.test/settings/connected-accounts?linked=github-taken"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const vault = createCredentialStore(database); +const identity = createIdentityStore(database, vault); +const usedSubjects: 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( + and( + eq(identityLinks.provider, "github"), + eq(identityLinks.realm, "github.com"), + inArray(identityLinks.subject, usedSubjects), + ), + ); + if (usedKeyPrefixes.length) + await database + .delete(credentials) + .where( + or( + ...usedKeyPrefixes.map((prefix) => + like(credentials.keyId, `${prefix}%`), + ), + ), + ); + 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, + links: Pick = identity, +) { + 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: links, + 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}:`; + 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() + .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 replaces the credential and revokes the previous one", async () => { + const account = githubAccount(); + const user = person(); + const prefix = 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(); + 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 says so 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(TAKEN); + const after = await linkFor(account); + expect(after).toMatchObject({ + 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(); +}); + +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(); + 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 new file mode 100644 index 000000000..58514ac55 --- /dev/null +++ b/server/tests/identity-github-callback.test.ts @@ -0,0 +1,498 @@ +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"; +const TAKEN = + "https://app.test/settings/connected-accounts?linked=github-taken"; + +type Harness = ReturnType; + +function build( + options: { + actorId?: string | null; + active?: boolean; + tokenFails?: boolean; + userFails?: boolean; + createError?: Error; + activeError?: Error; + linkError?: Error; + revokeError?: Error; + auditError?: Error; + nowAt?: Date; + } = {}, +) { + const fetchCalls: string[] = []; + const created: 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, + }); + } + if (options.userFails) return new Response("{}", { status: 401 }); + 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; + 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 }; + }, + revoke: async (id) => { + revoked.push(id); + if (options.revokeError) throw options.revokeError; + return new Date(); + }, + }, + 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) => { + if (options.auditError) throw options.auditError; + 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, + 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", + 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", + 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 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( + 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 () => { + 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.linked).toHaveLength(0); + }); + + test("an audit write that fails after the link committed still reports linked, and logs", async () => { + 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: { name: "PostgresError", errno: "08006" }, + }, + ), + }); + 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", + 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", + "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(), + }); + expect( + await call(harness, `code=the-code&state=${await validState()}`), + ).toBe(TAKEN); + expect(harness.revoked).toEqual(["cred-new"]); + expect(harness.audits).toHaveLength(0); + }); + + 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(TAKEN); + expect(harness.audits).toHaveLength(0); + expect(harness.revoked).toEqual(["cred-new"]); + }); + + 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(); + expect(text).not.toBe(""); + expect(errors.mock.calls.length).toBeGreaterThan(0); + for (const secret of [ + "the-code", + "gho_secret", + "ghr_secret", + clientSecret, + decodeURIComponent(state), + state, + ]) { + 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(stage === "link" ? TAKEN : 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"]); + }); + }); +}); 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, + ); + }); +}); diff --git a/server/tests/identity-providers.test.ts b/server/tests/identity-providers.test.ts new file mode 100644 index 000000000..2abc77e3e --- /dev/null +++ b/server/tests/identity-providers.test.ts @@ -0,0 +1,126 @@ +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 connection, installation and workspace", () => { + 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 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 app in a direct message in Slack: link ${code}`, + ); + expect(PROVIDERS.github.instruction).toBeUndefined(); +}); + +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); +}); + +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); +}); + +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-retire.integration.test.ts b/server/tests/identity-retire.integration.test.ts new file mode 100644 index 000000000..fce05f306 --- /dev/null +++ b/server/tests/identity-retire.integration.test.ts @@ -0,0 +1,450 @@ +import { afterAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +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, +} from "../src/db/schema"; +import { + retireIdentityLinks, + retireOwnedAccounts, +} from "../src/identity/retire"; +import { createIdentityStore, identityUserLock } 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(like(identityLinks.realm, `${realm}%`)); + if (users.length) + await database + .delete(identityLinkChallenges) + .where(inArray(identityLinkChallenges.userId, users)); + if (createdCredentials.length) + await database + .delete(credentials) + .where(inArray(credentials.id, createdCredentials)); + 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 = user(); + 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); + const audit = recorder(); + + expect( + await retireIdentityLinks( + database, + vault, + () => audit.store, + userId, + ADMIN, + ), + ).toBe(1); + + expect(await vault.isLive(credential.id)).toBe(false); + const row = await statusOf(linkId); + expect(row?.userId).toBe(userId); + expect(row?.credentialId).toBeNull(); + expect(row?.status).toBe("needs_reconnect"); + + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0]).toMatchObject({ + eventType: "identity.link_retired", + targetType: "identity_link", + targetId: linkId, + 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); + + // 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, + payload: { + actor: ADMIN, + owner: userId, + 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 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()]; + 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, + ); + + 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("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[] = []; + 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]); + // 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 () => { + expect( + await retireOwnedAccounts({ + plugins: async () => ({ retired: 2 }), + identities: async () => 3, + }), + ).toEqual({ retired: 5 }); +}); diff --git a/server/tests/identity-routes.test.ts b/server/tests/identity-routes.test.ts new file mode 100644 index 000000000..848326687 --- /dev/null +++ b/server/tests/identity-routes.test.ts @@ -0,0 +1,314 @@ +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, +} from "../src/identity/routes"; +import type { IdentityLink } 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 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(removedProvider = "github", options?: IdentityRouteOptions) { + const calls: string[] = []; + const events: AuditEventInput[] = []; + const issued: [string, 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" + ? { 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, issued }; +} + +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 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 () => { + const f = fixture(); + 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("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); + 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 () => { + 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"); +}); + +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([]); +}); + +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", + }); +}); + +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", + }); +}); + +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"); +}); + +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"); +}); diff --git a/server/tests/identity-schema.integration.test.ts b/server/tests/identity-schema.integration.test.ts new file mode 100644 index 000000000..d8f45e10e --- /dev/null +++ b/server/tests/identity-schema.integration.test.ts @@ -0,0 +1,138 @@ +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, + 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", + 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")); + 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")); + 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 () => { + 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" }); +}); diff --git a/server/tests/identity-slack-redeem.test.ts b/server/tests/identity-slack-redeem.test.ts new file mode 100644 index 000000000..f99a212b1 --- /dev/null +++ b/server/tests/identity-slack-redeem.test.ts @@ -0,0 +1,194 @@ +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"; +import { + IdentityConflictError, + IdentityInputError, + type IdentityLink, + IdentityLinkError, +} from "../src/identity/types"; + +const sender = { teamId: "T1", userId: "U1" }; + +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(); + }, + discardChallenge: async (...args: unknown[]) => { + discards.push(args); + return discarded(); + }, + } as unknown as Pick; + const auditStore: AuditStore = { + insert: async (event) => { + events.push(event); + }, + }; + return { calls, discards, 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("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 Object.assign( + new Error( + 'Failed query: insert into audit params: user-id-77,link-id-88 {"actor":"user-id-77"}', + ), + { + name: "DrizzleQueryError", + cause: { name: "PostgresError", errno: "08006" }, + }, + ); + }, + }; + 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", + 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 { + errors.mockRestore(); + } + }); + + test("works without an audit store", async () => { + 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", + ); + }); +}); diff --git a/server/tests/identity-store.integration.test.ts b/server/tests/identity-store.integration.test.ts new file mode 100644 index 000000000..0a5d3c7a7 --- /dev/null +++ b/server/tests/identity-store.integration.test.ts @@ -0,0 +1,427 @@ +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( + kind: "connector" | "model" = "connector", + provider = "github-user-token", +) { + const { id } = await vault.create({ + kind, + provider, + 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("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); + 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 () => { + 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)).toBeNull(); + expect(await store.linkedUser(identity)).not.toBeNull(); + 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)).toBeNull(); +}); + +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", + ); +}); + +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]); +}); + +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(); +}); + +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); +}); + +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(); +}); + +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(); + }, +); 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, );