diff --git a/.env.example b/.env.example
index a56977d..63fa2d5 100644
--- a/.env.example
+++ b/.env.example
@@ -11,21 +11,41 @@ TENANT_PACKAGE_DIR=../examples/fintech
# deployment mints, so its own conversations stay identifiable. Unset, the tenant package's id is
# used, which tells two packages apart but not two copies of one.
# DEPLOYMENT_ID=
-# Google sign-in. Leave commented for local development with OPENBOT_DEV_NO_AUTH; uncomment all five
-# settings together so authentication is either fully configured or absent.
-#
-# BETTER_AUTH_SECRET must be a high-entropy secret of at least 32 characters. Generate one:
-# openssl rand -base64 32
-# TRUSTED_ORIGINS is where the app is served from, which is port 3010 locally.
+# Sign-in. All of this is commented out, and a clone with none of it set is one administrator with
+# no sign-in at all, which is how you reach the product without registering an OAuth client first.
+# Somewhere other people can get to, an unconfigured deployment refuses to start rather than serving
+# an open one. OPENBOT_SINGLE_USER=true says you meant it.
+#
+# Configure ANY ONE of the three providers to turn sign-in on. Configure several and the sign-in
+# screen offers several, which is the normal shape for a company mid-migration.
+#
+# These four are needed whichever provider you pick:
+# BETTER_AUTH_URL is where OAuth callbacks come back to, which is the API on port 3001.
+# BETTER_AUTH_SECRET signs session cookies. At least 32 characters: openssl rand -base64 32
+# TRUSTED_ORIGINS is where the app is served from, which is port 3010 locally.
+# INITIAL_ADMIN_EMAILS names who is an administrator. Required, because nothing else grants the
+# role and no screen can promote somebody later. Re-read on every sign-in, so editing it works.
# BETTER_AUTH_URL=http://localhost:3001
# BETTER_AUTH_SECRET=
+# INITIAL_ADMIN_EMAILS=admin@example.com
+#
+# Google. Redirect URI: http://localhost:3001/api/auth/callback/google
# GOOGLE_OAUTH_CLIENT_ID=
# GOOGLE_OAUTH_CLIENT_SECRET=
-# INITIAL_ADMIN_EMAILS=admin@example.com
+#
+# Microsoft (Entra ID). Redirect URI: http://localhost:3001/api/auth/callback/microsoft
+# MICROSOFT_OAUTH_TENANT_ID defaults to `common`, which admits personal Microsoft accounts as well
+# as work ones. Put your directory GUID here if you mean only your own company.
+# MICROSOFT_OAUTH_CLIENT_ID=
+# MICROSOFT_OAUTH_CLIENT_SECRET=
+# MICROSOFT_OAUTH_TENANT_ID=common
+#
+# Okta. Redirect URI: http://localhost:3001/api/auth/callback/okta
+# The issuer is what makes it your Okta rather than Okta in general.
+# OKTA_OAUTH_CLIENT_ID=
+# OKTA_OAUTH_CLIENT_SECRET=
+# OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default
-# Local development only. This admits every request as one local administrator. Keep it explicit and
-# never expose a deployment to the internet in this state; production refuses to start with it.
-OPENBOT_DEV_NO_AUTH=true
TRUSTED_ORIGINS=http://localhost:3010
# CopilotKit Intelligence. Required: the server refuses to start without all four because
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0da5ede..2a14fee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -145,6 +145,12 @@ jobs:
- name: The image boots and serves
run: |
set -euo pipefail
+ # `OPENBOT_SINGLE_USER=true` because this boots a deployment with no identity provider, and
+ # the image sets NODE_ENV=production, where that combination refuses to start rather than
+ # serve an open deployment. Saying so is the point: the flag is how somebody declares they
+ # meant it. This was `OPENBOT_DEV_NO_AUTH=1` before, which the code never accepted at all,
+ # since it compares against the exact string "true"; it did nothing and nothing noticed.
+ #
# Placeholders, not secrets. `loadConfig` refuses to start without Intelligence and a
# licence configured, but it only checks that they are present and well-formed; nothing is
# contacted at start-up and /api/capabilities reads config alone. So this proves the image
@@ -155,7 +161,7 @@ jobs:
-e EMBEDDED_POSTGRES=on \
-e KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
-e TRUSTED_ORIGINS=http://localhost:3001 \
- -e OPENBOT_DEV_NO_AUTH=1 \
+ -e OPENBOT_SINGLE_USER=true \
-e MANAGED_AGENT_AG_UI_URL=http://127.0.0.1:4201/ag-ui \
-e MANAGED_AGENT_TOKEN=ci-not-a-real-token \
-e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9067e8a..945ff42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
## Unreleased
+### Upgrading
+
+Two configurations now refuse to start:
+
+- A provider configured with no `INITIAL_ADMIN_EMAILS`. Set it to at least one address.
+- No provider at all with `NODE_ENV=production`. Configure one, or set `OPENBOT_SINGLE_USER=true`.
+
+Sessions survive and nobody signs in again.
+
### Added
- **Releases are cut by a workflow, not by hand.** `Create release PR` bumps the version and promotes
@@ -22,6 +31,24 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
supervised service is respawning. A single `verify` check covers every job, so branch protection
needs one entry. The same checks run again against the release commit when a release is published,
so they gate the release rather than the proposal for one.
+- **Sign in with Google, Microsoft or Okta.** Any one of them turns sign-in on; configure several
+ and the sign-in screen offers each, on matching buttons carrying each provider's own mark.
+ `INITIAL_ADMIN_EMAILS` says who is an administrator. It is required whenever a provider is
+ configured, because nothing else grants the role, and it is now a floor rather than a one-off:
+ an address it names is made an administrator at every sign-in, so adding somebody to the list
+ works even after they have already signed in.
+- **SAML and OpenID Connect, registered while running.** `/admin/identity-providers` takes the
+ metadata a company's identity team supplies and registers their own IdP. Somebody then types their
+ email address on the sign-in screen and the domain decides which provider they are sent to, so a
+ company mid-merger can run two. Registering, changing or removing one is administrator-only, which
+ the upstream plugin does not require: it guards those routes with a session, and anybody who could
+ reach them could register a provider for a domain and mint themselves colleagues.
+- **A People screen.** `/admin/people` lists everybody who has signed in, with the provider they came
+ through and when they were last here, and lets an administrator promote, demote, or remove
+ somebody. Removing ends the session they are using and stops the next sign-in, keyed on the
+ address so signing in again through the provider does not quietly create a new account. Every
+ change is on the audit trail. Somebody named in `INITIAL_ADMIN_EMAILS` cannot be demoted or
+ removed here, and nobody can do either to themselves.
- **One container that runs the whole thing.** The root `Dockerfile` builds an image carrying the
app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point `DATABASE_URL`
at a database you already run and the built-in one never starts; leave it unset and the container
@@ -92,6 +119,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
### Changed
+- **A deployment with no identity provider is one administrator, without a flag.** That is how a
+ fresh clone reaches the product. Where `NODE_ENV=production`, an unconfigured deployment now
+ refuses to start instead, because a public URL where every visitor is an administrator is silent
+ and looks like it works. `OPENBOT_SINGLE_USER=true` replaces `OPENBOT_DEV_NO_AUTH`, which is still
+ honoured, and is how somebody says they meant an open deployment.
+- **Requires Better Auth 1.7**, which adds an `issuer` to every account. Migrations `0002` to `0004`
+ add the column, backfill existing rows with their provider's real issuer, and then make it
+ required, so nobody is asked to sign in again.
- **Where a Bot's computer runs is now a plug.** One `ComputerProvider` interface sits under the
gateway, with the Docker supervisor as one implementation and a shared computer as another. A
computer somewhere else is an adapter rather than a change to the governed path. Thanks to
diff --git a/README.md b/README.md
index 4ad4e5b..12e5862 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ your own machine.
> **Alpha, and under active development.** OpenBot is early. Expect rough edges and bugs, and expect things to move. Issues and pull requests are welcome.
-> **Runs on your machine.** Everything below is written for a laptop. Out of the box OpenBot runs with `OPENBOT_DEV_NO_AUTH`, which skips signing in and admits every request as one administrator. [Google sign-in](#sign-in-with-google) can be wired up instead.
+> **Runs on your machine.** Everything below is written for a laptop. With no identity provider configured OpenBot admits every request as one administrator, so a fresh clone reaches the product without registering an OAuth client. [Sign-in](#sign-in) turns that off.
## What it is
@@ -149,6 +149,8 @@ as one replica for now.
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
- **Governed MCP**: a curated catalogue ships for Atlassian, Box, Slack, Salesforce and ServiceNow. Custom servers must pass URL checks, and any tool not positively classified as a read is treated as a write.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
+- **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once.
+- **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail.
- **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it.
- **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events.
- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port.
@@ -191,7 +193,7 @@ Settings worth knowing:
| Variable | Use |
| ------------------------------------ | ------------------------------------------------------------------------- |
-| `OPENBOT_DEV_NO_AUTH` | Admits every request as one administrator. How OpenBot runs today. |
+| `OPENBOT_SINGLE_USER` | Admits every request as one administrator where an unconfigured deployment would otherwise refuse to start. |
| `OPENAI_BASE_URL` | Answers the OpenAI-shaped calls from somewhere else: a gateway, a proxy. |
| `ANTHROPIC_BASE_URL`, `GOOGLE_GENERATIVE_AI_BASE_URL` | The same, for those two APIs. |
| `COMPUTER_TOKEN` | Secret every Bot computer request must present. `start.sh` sets one. |
@@ -227,25 +229,58 @@ endpoints; keep them private and do not use them to bypass the gateway.
More detail: [docs/architecture.md](docs/architecture.md).
-## Sign in with Google
+## Sign in
-`OPENBOT_DEV_NO_AUTH` is the default because it needs no OAuth credentials and no consent screen. To sign in for real instead, create a Google OAuth client and set all four of these together:
+Nothing configured means one administrator and no sign-in, which is how a fresh clone reaches the
+product. Configure **any one** of Google, Microsoft or Okta to turn sign-in on. Configure more than
+one and the sign-in screen offers each of them.
+
+These four are needed whichever you pick:
```sh
-BETTER_AUTH_URL=http://localhost:3001
-BETTER_AUTH_SECRET= # openssl rand -base64 32, at least 32 characters
-GOOGLE_OAUTH_CLIENT_ID=
-GOOGLE_OAUTH_CLIENT_SECRET=
+BETTER_AUTH_URL=http://localhost:3001 # where OAuth callbacks come back to
+BETTER_AUTH_SECRET= # openssl rand -base64 32
+TRUSTED_ORIGINS=http://localhost:3010 # where the app is served from
+INITIAL_ADMIN_EMAILS=you@example.com # comma separated
```
-Then set the two that decide who gets in and from where:
+Then the provider. Register the redirect URI shown beside it.
+
+```sh
+# Google — http://localhost:3001/api/auth/callback/google
+GOOGLE_OAUTH_CLIENT_ID=
+GOOGLE_OAUTH_CLIENT_SECRET=
-- `TRUSTED_ORIGINS` — where the app is served from, `http://localhost:3010` locally. It defaults to `http://localhost:3000`, which is not where `start.sh` serves the app.
-- `INITIAL_ADMIN_EMAILS` — comma separated. An address listed here becomes an administrator the first time it signs in; everybody else becomes a user.
+# Microsoft — http://localhost:3001/api/auth/callback/microsoft
+MICROSOFT_OAUTH_CLIENT_ID=
+MICROSOFT_OAUTH_CLIENT_SECRET=
+MICROSOFT_OAUTH_TENANT_ID=common # your directory GUID for staff only
-Remove `OPENBOT_DEV_NO_AUTH`, then restart: the sign-in button is written into the app's generated config at startup, so it appears only once all four settings are present. Accounts, sessions and roles are stored in the same PostgreSQL database as everything else.
+# Okta — http://localhost:3001/api/auth/callback/okta
+OKTA_OAUTH_CLIENT_ID=
+OKTA_OAUTH_CLIENT_SECRET=
+OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default
+```
-A partial set is refused rather than ignored: the server will not start with `BETTER_AUTH_SECRET` or `BETTER_AUTH_URL` but no client credentials, or with a secret shorter than 32 characters.
+Restart. Accounts, sessions and roles are stored in the same PostgreSQL database as everything else.
+
+- `INITIAL_ADMIN_EMAILS` is required, because nothing else grants the administrator role and no
+ screen can promote somebody afterwards. It is re-read on every sign-in, so editing it takes effect
+ the next time that person signs in.
+- `MICROSOFT_OAUTH_TENANT_ID` defaults to `common`, which admits personal Microsoft accounts as well
+ as work ones. On a multi-tenant app registration Entra may send no `email` claim at all, so
+ OpenBot falls back to `upn` and then `preferred_username`. If none of the three arrives the
+ sign-in is refused and the reason is logged: add `email` as an optional claim, or use your
+ directory GUID here.
+- A half-configured provider is refused at start-up rather than at somebody's first attempt to sign
+ in: a client id with no secret, a secret shorter than 32 characters, or an Okta issuer with no
+ credentials behind it.
+- **SAML and OIDC** are registered while the deployment runs rather than configured here. Sign in as
+ an administrator and go to Admin → Identity providers with the metadata your identity team gave
+ you. People then sign in by typing their email address, and the domain decides which provider
+ they are sent to.
+- **Put TLS in front of any deployment.** A page served over plain `http://` on anything but
+ localhost is not a secure context, and sign-in cookies want `Secure`.
## Keeping it to your machine
@@ -280,6 +315,8 @@ Use `bash scripts/start.sh` for the whole stack. Use `bun run dev` only when you
- [docs/configuration.md](docs/configuration.md)
- [docs/development.md](docs/development.md)
- [docs/coworkers.md](docs/coworkers.md)
+- [docs/deployment.md](docs/deployment.md)
+- [docs/releasing.md](docs/releasing.md)
## Contributing
diff --git a/app/package.json b/app/package.json
index 8fd0e46..d9cf978 100644
--- a/app/package.json
+++ b/app/package.json
@@ -15,6 +15,7 @@
"dependencies": {
"@ag-ui/core": "0.0.57",
"@base-ui/react": "^1.6.0",
+ "@better-auth/sso": "^1.7.1",
"@copilotkit/react-core": "1.68.3",
"@fontsource-variable/inter": "^5.3.0",
"@shadcn/react": "^0.3.0",
diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx
index a5730ac..6bda384 100644
--- a/app/src/components/admin/admin-sidebar.tsx
+++ b/app/src/components/admin/admin-sidebar.tsx
@@ -1,5 +1,6 @@
import {
IconArrowLeft,
+ IconBuildingBank,
IconCode,
IconDeviceDesktop,
IconKey,
@@ -8,6 +9,7 @@ import {
IconPlugConnected,
IconPuzzle,
IconShieldCheck,
+ IconUsers,
} from "@tabler/icons-react";
import { Link, type LinkOptions } from "@tanstack/react-router";
import type * as React from "react";
@@ -86,6 +88,21 @@ const GROUPS: {
},
],
},
+ {
+ label: "Who can get in",
+ items: [
+ {
+ title: "People",
+ icon: IconUsers,
+ linkOptions: { to: "/admin/people" },
+ },
+ {
+ title: "Identity providers",
+ icon: IconBuildingBank,
+ linkOptions: { to: "/admin/identity-providers" },
+ },
+ ],
+ },
{
label: "What happened",
items: [
diff --git a/app/src/components/auth/provider-logo.tsx b/app/src/components/auth/provider-logo.tsx
new file mode 100644
index 0000000..5af971b
--- /dev/null
+++ b/app/src/components/auth/provider-logo.tsx
@@ -0,0 +1,96 @@
+import type { AuthProviderId } from "@/lib/auth/queries";
+
+/**
+ * The mark each identity provider requires on a sign-in button.
+ *
+ * Drawn inline rather than fetched. These sit on the one screen somebody reaches before they have a
+ * session, so a mark that arrives over the network is a mark that can be missing exactly when the
+ * page has to be trustworthy, and a request to a third party from an unauthenticated page is a
+ * request nobody asked for.
+ *
+ * Reproduced at their published colours because two of the three require it. Google's guidelines say
+ * the standard colour G, at its own aspect ratio, neither recoloured nor restretched, and Microsoft
+ * publish the four squares the same way. They are trade marks used to say "this button signs you in
+ * with them", which is what the guidelines are for.
+ *
+ * All three are drawn into the same 18x18 box so the buttons line up. Google's G is not square, so
+ * it is centred in the box rather than stretched to fill it.
+ */
+export function ProviderLogo({ provider }: { provider: AuthProviderId }) {
+ if (provider === "google") return ;
+ if (provider === "microsoft") return ;
+ return ;
+}
+
+/** Google's four-colour G, at the published path and colours. */
+function GoogleMark() {
+ return (
+
+ );
+}
+
+/** Microsoft's four squares, at their published colours. */
+function MicrosoftMark() {
+ return (
+
+ );
+}
+
+/**
+ * Okta's circular mark.
+ *
+ * `currentColor` rather than Okta blue, which is the one difference between this and the other two.
+ * Okta is not a consumer sign-in button somebody recognises by colour; it is whichever Okta the
+ * company running this deployment happens to use, and their guidelines allow a monochrome mark. It
+ * also means it stays legible in both themes without a second asset.
+ */
+function OktaMark() {
+ return (
+
+ );
+}
diff --git a/app/src/lib/auth/client.ts b/app/src/lib/auth/client.ts
index ff1f39b..ebf9866 100644
--- a/app/src/lib/auth/client.ts
+++ b/app/src/lib/auth/client.ts
@@ -1,14 +1,84 @@
+import { ssoClient } from "@better-auth/sso/client";
import { createAuthClient } from "better-auth/react";
+import type { AuthProviderId } from "./queries";
-export const authClient = createAuthClient();
+export const authClient = createAuthClient({ plugins: [ssoClient()] });
-export async function signInWithGoogle() {
- const result = await authClient.signIn.social({
- provider: "google" as never,
+/** What each provider is called on the button, since none of them are called by their id. */
+const PROVIDER_NAMES: Record = {
+ google: "Google",
+ microsoft: "Microsoft",
+ okta: "Okta",
+};
+
+export function providerName(provider: AuthProviderId): string {
+ return PROVIDER_NAMES[provider];
+}
+
+/** What a sign-in attempt came back with, which is either nothing or a reason. */
+type SocialResult = { error?: { message?: string } | null };
+
+/**
+ * Start sign-in with one provider.
+ *
+ * One call for all three, including Okta. Okta is served by the generic OAuth plugin rather than as
+ * a named provider, but the plugin registers under a provider id like any other, so the browser does
+ * not need to know which kind it is asking for. Keeping that distinction on the server is the point:
+ * a deployment can gain a provider without the app being rebuilt.
+ *
+ * `start` is injectable because Better Auth's client is a proxy, so a test cannot replace the method
+ * on it. Named so it cannot shadow anything it defaults to.
+ */
+export async function signInWith(
+ provider: AuthProviderId,
+ start: (input: {
+ provider: string;
+ callbackURL: string;
+ }) => Promise = (input) =>
+ authClient.signIn.social(input as never) as Promise,
+) {
+ const result = await start({
+ provider,
callbackURL: window.location.origin,
});
if (result.error) {
- throw new Error(result.error.message ?? "Could not start Google sign-in.");
+ // Naming the provider matters more with three buttons than it did with one: "Could not start
+ // sign-in" leaves somebody looking at three of them with no idea which one refused.
+ throw new Error(
+ result.error.message ||
+ `Could not start ${providerName(provider)} sign-in.`,
+ );
+ }
+}
+
+/**
+ * Start sign-in through whichever identity provider covers this address.
+ *
+ * The email is not a credential here and no password is asked for: only the part after the @ is
+ * used, to decide which registered provider to hand somebody to. A company with two IdPs mid-merger
+ * has two domains, and this is how somebody reaches theirs without being asked which one they are.
+ *
+ * Injectable for the same reason as `signInWith`: the Better Auth client is a proxy.
+ */
+export async function signInWithEmailDomain(
+ email: string,
+ start: (input: {
+ email: string;
+ callbackURL: string;
+ }) => Promise = (input) =>
+ (
+ authClient as unknown as {
+ signIn: { sso: (i: unknown) => Promise };
+ }
+ ).signIn.sso(input),
+) {
+ const result = await start({ email, callbackURL: window.location.origin });
+
+ if (result.error) {
+ throw new Error(
+ result.error.message ||
+ "No identity provider is registered for that address.",
+ );
}
}
diff --git a/app/src/lib/auth/queries.ts b/app/src/lib/auth/queries.ts
index aee0db5..6c4ded5 100644
--- a/app/src/lib/auth/queries.ts
+++ b/app/src/lib/auth/queries.ts
@@ -1,5 +1,5 @@
import { queryOptions } from "@tanstack/react-query";
-import { tryClient } from "@/lib/client";
+import { client, tryClient } from "@/lib/client";
export type AuthenticatedUser = {
id: string;
@@ -12,8 +12,53 @@ export type AuthenticatedUser = {
export const authKeys = {
all: ["auth"] as const,
currentUser: () => [...authKeys.all, "current-user"] as const,
+ providers: () => [...authKeys.all, "providers"] as const,
};
+/** An identity provider this deployment can sign somebody in with. */
+export type AuthProviderId = "google" | "microsoft" | "okta";
+
+/** What the sign-in screen may offer, answered by the process that knows. */
+export type SignInOptions = {
+ providers: AuthProviderId[];
+ /**
+ * Whether any enterprise identity provider is registered.
+ *
+ * A boolean, not a list: naming them would tell anybody who loads the sign-in page which companies
+ * use this deployment, before they have signed in.
+ */
+ sso: boolean;
+};
+
+async function signInOptions(): Promise {
+ // The whole body, so both fields arrive together. Reading a field off the Response `client`
+ // returns without a key quietly yields undefined: the screen would say no provider is configured
+ // while the server was saying it has one.
+ const body = (await (
+ await client("/api/capabilities", { fallback: "Could not load sign-in" })
+ ).json()) as { authProviders?: AuthProviderId[]; ssoConfigured?: boolean };
+
+ return {
+ providers: body.authProviders ?? [],
+ sso: body.ssoConfigured === true,
+ };
+}
+
+/**
+ * Which providers the sign-in screen may offer.
+ *
+ * From the server rather than from the build. The image is built once with no deployment
+ * environment, so a list compiled into the bundle can only ever describe the build machine.
+ */
+export function authProvidersQueryOptions() {
+ return queryOptions({
+ queryKey: authKeys.providers(),
+ queryFn: signInOptions,
+ // Configuration, not data. It cannot change without the process restarting.
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+}
+
async function currentUser(): Promise {
/*
* `tryClient` rather than `client`: not being signed in is an answer here, not a failure, and it
diff --git a/app/src/lib/identity-providers/mutations.ts b/app/src/lib/identity-providers/mutations.ts
new file mode 100644
index 0000000..fec6cf3
--- /dev/null
+++ b/app/src/lib/identity-providers/mutations.ts
@@ -0,0 +1,94 @@
+import { mutationOptions, type QueryClient } from "@tanstack/react-query";
+import { client } from "@/lib/client";
+import { identityProviderKeys } from "./queries";
+
+const FALLBACK = "Could not change that identity provider";
+
+function invalidateProviders(queryClient: QueryClient) {
+ return queryClient.invalidateQueries({ queryKey: identityProviderKeys.all });
+}
+
+/**
+ * What an administrator has to supply to register an identity provider.
+ *
+ * SAML wants the metadata XML their identity team gives them, which carries the entry point and the
+ * signing certificate. OIDC wants an issuer and a client, because there is no equivalent document to
+ * paste. Both want the domain, since that is what routes somebody to this provider rather than
+ * another.
+ */
+export type IdentityProviderInput =
+ | {
+ protocol: "saml";
+ providerId: string;
+ domain: string;
+ issuer: string;
+ entryPoint: string;
+ metadata: string;
+ }
+ | {
+ protocol: "oidc";
+ providerId: string;
+ domain: string;
+ issuer: string;
+ clientId: string;
+ clientSecret: string;
+ };
+
+/** The body Better Auth's own route expects, which is not the shape the form collects. */
+function registerBody(input: IdentityProviderInput) {
+ const common = {
+ providerId: input.providerId,
+ issuer: input.issuer,
+ domain: input.domain,
+ };
+
+ if (input.protocol === "saml") {
+ return {
+ ...common,
+ samlConfig: {
+ entryPoint: input.entryPoint,
+ idpMetadata: { metadata: input.metadata },
+ },
+ };
+ }
+
+ return {
+ ...common,
+ oidcConfig: {
+ clientId: input.clientId,
+ clientSecret: input.clientSecret,
+ // Let the provider describe itself rather than asking somebody to type six endpoints.
+ discoveryEndpoint: `${input.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`,
+ },
+ };
+}
+
+export function registerIdentityProviderMutationOptions(
+ queryClient: QueryClient,
+) {
+ return mutationOptions({
+ mutationFn: async (input: IdentityProviderInput): Promise => {
+ await client("/api/auth/sso/register", {
+ method: "POST",
+ body: registerBody(input),
+ fallback: FALLBACK,
+ });
+ },
+ onSuccess: () => invalidateProviders(queryClient),
+ });
+}
+
+export function deleteIdentityProviderMutationOptions(
+ queryClient: QueryClient,
+) {
+ return mutationOptions({
+ mutationFn: async (providerId: string): Promise => {
+ await client("/api/auth/sso/delete-provider", {
+ method: "POST",
+ body: { providerId },
+ fallback: FALLBACK,
+ });
+ },
+ onSuccess: () => invalidateProviders(queryClient),
+ });
+}
diff --git a/app/src/lib/identity-providers/queries.ts b/app/src/lib/identity-providers/queries.ts
new file mode 100644
index 0000000..1c9e3b3
--- /dev/null
+++ b/app/src/lib/identity-providers/queries.ts
@@ -0,0 +1,58 @@
+import { queryOptions } from "@tanstack/react-query";
+import { client } from "@/lib/client";
+
+/**
+ * An identity provider a company registered, rather than one this deployment was configured with.
+ *
+ * The three in the environment are Google, Entra and Okta. These are somebody's own: registered
+ * while the deployment is running, from metadata their identity team supplied, and there can be
+ * several. A company mid-merger has two.
+ */
+export type IdentityProvider = {
+ providerId: string;
+ issuer: string;
+ /** The email domain that routes somebody here. */
+ domain: string;
+ /** Which protocol it speaks. SAML is what most enterprise identity teams hand over. */
+ protocol: "saml" | "oidc";
+};
+
+export const identityProviderKeys = {
+ all: ["identity-providers"] as const,
+ list: () => ["identity-providers", "list"] as const,
+};
+
+/**
+ * The registered providers.
+ *
+ * Read from Better Auth's own route rather than one of ours, because the plugin owns the table and a
+ * second reader would be a second answer. The payload carries no client secret or signing key: the
+ * fields below are all this asks for.
+ */
+export function identityProviderListQueryOptions() {
+ return queryOptions({
+ queryKey: identityProviderKeys.list(),
+ queryFn: async (): Promise => {
+ // `{ providers: [...] }`, not a bare array. Better Auth's own routes carry their own
+ // envelope, which is why this reads the body rather than passing a key to `client`.
+ const response = await client("/api/auth/sso/providers", {
+ fallback: "Could not load identity providers",
+ });
+ const { providers = [] } = (await response.json()) as {
+ providers?: {
+ providerId: string;
+ issuer: string;
+ domain: string;
+ samlConfig?: unknown;
+ }[];
+ };
+
+ return providers.map((provider) => ({
+ providerId: provider.providerId,
+ issuer: provider.issuer,
+ domain: provider.domain,
+ protocol: provider.samlConfig ? "saml" : "oidc",
+ }));
+ },
+ });
+}
diff --git a/app/src/lib/people/mutations.ts b/app/src/lib/people/mutations.ts
new file mode 100644
index 0000000..0a5721b
--- /dev/null
+++ b/app/src/lib/people/mutations.ts
@@ -0,0 +1,45 @@
+import { mutationOptions, type QueryClient } from "@tanstack/react-query";
+import { client } from "@/lib/client";
+import { type Person, peopleKeys } from "./queries";
+
+const FALLBACK = "Could not update that person";
+
+function invalidatePeople(queryClient: QueryClient) {
+ return queryClient.invalidateQueries({ queryKey: peopleKeys.all });
+}
+
+export function setPersonRoleMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: (variables: {
+ userId: string;
+ role: "admin" | "user";
+ }): Promise =>
+ client(`/api/admin/people/${variables.userId}/role`, "person", {
+ method: "POST",
+ body: { role: variables.role },
+ fallback: FALLBACK,
+ }),
+ onSuccess: () => invalidatePeople(queryClient),
+ });
+}
+
+/**
+ * Remove somebody's access, or give it back.
+ *
+ * One mutation rather than two, because the row is a single decision with two directions and the
+ * screen renders the same control either way.
+ */
+export function setPersonAccessMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: (variables: {
+ userId: string;
+ revoked: boolean;
+ }): Promise =>
+ client(`/api/admin/people/${variables.userId}/access`, "person", {
+ method: "POST",
+ body: { revoked: variables.revoked },
+ fallback: FALLBACK,
+ }),
+ onSuccess: () => invalidatePeople(queryClient),
+ });
+}
diff --git a/app/src/lib/people/queries.ts b/app/src/lib/people/queries.ts
new file mode 100644
index 0000000..7fff1c2
--- /dev/null
+++ b/app/src/lib/people/queries.ts
@@ -0,0 +1,43 @@
+import { queryOptions } from "@tanstack/react-query";
+import { client } from "@/lib/client";
+
+/**
+ * Somebody who has signed in to this deployment.
+ *
+ * People are here because they signed in, not because they were invited: the identity provider
+ * decides who exists, and this screen decides what they may do once they are here.
+ */
+export type Person = {
+ id: string;
+ email: string;
+ name: string | null;
+ image: string | null;
+ role: "admin" | "user";
+ /** The providers they have arrived through. More than one is normal mid-migration. */
+ providers: string[];
+ lastSignedInAt: string | null;
+ /** Whether an administrator has removed them. They keep their row and their history. */
+ revoked: boolean;
+ /**
+ * Whether the deployment's configuration fixes their role.
+ *
+ * The server's verdict, rendered rather than recomputed here: this screen does not know what is in
+ * `INITIAL_ADMIN_EMAILS` and should not try to work it out from anything else.
+ */
+ configuredAdmin: boolean;
+};
+
+export const peopleKeys = {
+ all: ["people"] as const,
+ list: () => ["people", "list"] as const,
+};
+
+export function peopleListQueryOptions() {
+ return queryOptions({
+ queryKey: peopleKeys.list(),
+ queryFn: (): Promise =>
+ client("/api/admin/people", "people", {
+ fallback: "Could not load people",
+ }),
+ });
+}
diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts
index 48a0bf9..c7fae13 100644
--- a/app/src/routeTree.gen.ts
+++ b/app/src/routeTree.gen.ts
@@ -23,6 +23,8 @@ import { Route as AuthedAdminBoundariesRouteImport } from './routes/_authed/admi
import { Route as AuthedAdminComputersRouteImport } from './routes/_authed/admin/computers'
import { Route as AuthedAdminConnectorsRouteImport } from './routes/_authed/admin/connectors'
import { Route as AuthedAdminCredentialsRouteImport } from './routes/_authed/admin/credentials'
+import { Route as AuthedAdminIdentityProvidersRouteImport } from './routes/_authed/admin/identity-providers'
+import { Route as AuthedAdminPeopleRouteImport } from './routes/_authed/admin/people'
import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground'
import { Route as AuthedAdminPluginsRouteImport } from './routes/_authed/admin/plugins'
import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index'
@@ -103,6 +105,17 @@ const AuthedAdminCredentialsRoute = AuthedAdminCredentialsRouteImport.update({
path: '/credentials',
getParentRoute: () => AuthedAdminRouteRoute,
} as any)
+const AuthedAdminIdentityProvidersRoute =
+ AuthedAdminIdentityProvidersRouteImport.update({
+ id: '/identity-providers',
+ path: '/identity-providers',
+ getParentRoute: () => AuthedAdminRouteRoute,
+ } as any)
+const AuthedAdminPeopleRoute = AuthedAdminPeopleRouteImport.update({
+ id: '/people',
+ path: '/people',
+ getParentRoute: () => AuthedAdminRouteRoute,
+} as any)
const AuthedAdminPlaygroundRoute = AuthedAdminPlaygroundRouteImport.update({
id: '/playground',
path: '/playground',
@@ -177,6 +190,8 @@ export interface FileRoutesByFullPath {
'/admin/computers': typeof AuthedAdminComputersRoute
'/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/admin/credentials': typeof AuthedAdminCredentialsRoute
+ '/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute
+ '/admin/people': typeof AuthedAdminPeopleRoute
'/admin/playground': typeof AuthedAdminPlaygroundRoute
'/admin/plugins': typeof AuthedAdminPluginsRoute
'/admin/': typeof AuthedAdminIndexRoute
@@ -200,6 +215,8 @@ export interface FileRoutesByTo {
'/admin/computers': typeof AuthedAdminComputersRoute
'/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/admin/credentials': typeof AuthedAdminCredentialsRoute
+ '/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute
+ '/admin/people': typeof AuthedAdminPeopleRoute
'/admin/playground': typeof AuthedAdminPlaygroundRoute
'/admin/plugins': typeof AuthedAdminPluginsRoute
'/admin': typeof AuthedAdminIndexRoute
@@ -227,6 +244,8 @@ export interface FileRoutesById {
'/_authed/admin/computers': typeof AuthedAdminComputersRoute
'/_authed/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/_authed/admin/credentials': typeof AuthedAdminCredentialsRoute
+ '/_authed/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute
+ '/_authed/admin/people': typeof AuthedAdminPeopleRoute
'/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute
'/_authed/admin/plugins': typeof AuthedAdminPluginsRoute
'/_authed/_app/': typeof AuthedAppIndexRoute
@@ -255,6 +274,8 @@ export interface FileRouteTypes {
| '/admin/computers'
| '/admin/connectors'
| '/admin/credentials'
+ | '/admin/identity-providers'
+ | '/admin/people'
| '/admin/playground'
| '/admin/plugins'
| '/admin/'
@@ -278,6 +299,8 @@ export interface FileRouteTypes {
| '/admin/computers'
| '/admin/connectors'
| '/admin/credentials'
+ | '/admin/identity-providers'
+ | '/admin/people'
| '/admin/playground'
| '/admin/plugins'
| '/admin'
@@ -304,6 +327,8 @@ export interface FileRouteTypes {
| '/_authed/admin/computers'
| '/_authed/admin/connectors'
| '/_authed/admin/credentials'
+ | '/_authed/admin/identity-providers'
+ | '/_authed/admin/people'
| '/_authed/admin/playground'
| '/_authed/admin/plugins'
| '/_authed/_app/'
@@ -424,6 +449,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminCredentialsRouteImport
parentRoute: typeof AuthedAdminRouteRoute
}
+ '/_authed/admin/identity-providers': {
+ id: '/_authed/admin/identity-providers'
+ path: '/identity-providers'
+ fullPath: '/admin/identity-providers'
+ preLoaderRoute: typeof AuthedAdminIdentityProvidersRouteImport
+ parentRoute: typeof AuthedAdminRouteRoute
+ }
+ '/_authed/admin/people': {
+ id: '/_authed/admin/people'
+ path: '/people'
+ fullPath: '/admin/people'
+ preLoaderRoute: typeof AuthedAdminPeopleRouteImport
+ parentRoute: typeof AuthedAdminRouteRoute
+ }
'/_authed/admin/playground': {
id: '/_authed/admin/playground'
path: '/playground'
@@ -523,6 +562,8 @@ interface AuthedAdminRouteRouteChildren {
AuthedAdminComputersRoute: typeof AuthedAdminComputersRoute
AuthedAdminConnectorsRoute: typeof AuthedAdminConnectorsRouteWithChildren
AuthedAdminCredentialsRoute: typeof AuthedAdminCredentialsRoute
+ AuthedAdminIdentityProvidersRoute: typeof AuthedAdminIdentityProvidersRoute
+ AuthedAdminPeopleRoute: typeof AuthedAdminPeopleRoute
AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute
AuthedAdminPluginsRoute: typeof AuthedAdminPluginsRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
@@ -536,6 +577,8 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = {
AuthedAdminComputersRoute: AuthedAdminComputersRoute,
AuthedAdminConnectorsRoute: AuthedAdminConnectorsRouteWithChildren,
AuthedAdminCredentialsRoute: AuthedAdminCredentialsRoute,
+ AuthedAdminIdentityProvidersRoute: AuthedAdminIdentityProvidersRoute,
+ AuthedAdminPeopleRoute: AuthedAdminPeopleRoute,
AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute,
AuthedAdminPluginsRoute: AuthedAdminPluginsRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
diff --git a/app/src/routes/_authed/admin/identity-providers.tsx b/app/src/routes/_authed/admin/identity-providers.tsx
new file mode 100644
index 0000000..64f2123
--- /dev/null
+++ b/app/src/routes/_authed/admin/identity-providers.tsx
@@ -0,0 +1,308 @@
+import { IconBuildingBank, IconTrash } from "@tabler/icons-react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { createFileRoute } from "@tanstack/react-router";
+import { useState } from "react";
+import {
+ PageEmpty,
+ PageRows,
+ PageSection,
+ PageShell,
+} from "@/components/layout/page-shell";
+import { StaggerItem } from "@/components/layout/stagger";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ deleteIdentityProviderMutationOptions,
+ type IdentityProviderInput,
+ registerIdentityProviderMutationOptions,
+} from "@/lib/identity-providers/mutations";
+import { identityProviderListQueryOptions } from "@/lib/identity-providers/queries";
+import { queryClient } from "@/query-client";
+
+export const Route = createFileRoute("/_authed/admin/identity-providers")({
+ component: IdentityProvidersPage,
+});
+
+const EMPTY = {
+ protocol: "saml" as "saml" | "oidc",
+ providerId: "",
+ domain: "",
+ issuer: "",
+ entryPoint: "",
+ metadata: "",
+ clientId: "",
+ clientSecret: "",
+};
+
+function IdentityProvidersPage() {
+ const providers = useQuery(identityProviderListQueryOptions());
+ const register = useMutation(
+ registerIdentityProviderMutationOptions(queryClient),
+ );
+ const remove = useMutation(
+ deleteIdentityProviderMutationOptions(queryClient),
+ );
+ const [open, setOpen] = useState(false);
+ const [draft, setDraft] = useState(EMPTY);
+
+ const failure = register.error ?? remove.error;
+
+ function submit(submission: React.FormEvent) {
+ submission.preventDefault();
+ const input: IdentityProviderInput =
+ draft.protocol === "saml"
+ ? {
+ protocol: "saml",
+ providerId: draft.providerId,
+ domain: draft.domain,
+ issuer: draft.issuer,
+ entryPoint: draft.entryPoint,
+ metadata: draft.metadata,
+ }
+ : {
+ protocol: "oidc",
+ providerId: draft.providerId,
+ domain: draft.domain,
+ issuer: draft.issuer,
+ clientId: draft.clientId,
+ clientSecret: draft.clientSecret,
+ };
+
+ register.mutate(input, {
+ onSuccess: () => {
+ setDraft(EMPTY);
+ setOpen(false);
+ },
+ });
+ }
+
+ return (
+ setOpen(true)} size="lg">
+ Add a provider
+
+ }
+ description="A company's own identity provider, by SAML or OpenID Connect. Somebody types their email address and the domain decides which one they are sent to."
+ title="Identity providers"
+ >
+
+ {failure ? (
+
+ ) : people.data?.length === 0 ? (
+
+ Nobody has signed in yet. People appear here once they do.
+
+ ) : (
+
+ {people.data?.map((person, index) => {
+ const isSelf = person.id === currentUser.data?.id;
+ const busy = setRole.isPending || setAccess.isPending;
+
+ return (
+
+
+
+ {person.revoked ? (
+
+ ) : person.role === "admin" ? (
+
+ ) : (
+
+ )}
+
+
+ {person.name ?? person.email}
+
+ {person.name ? `${person.email} · ` : ""}
+ {describe(person)}
+
+
+
+ {/*
+ * Removing access is the louder decision, so it is a button rather than a
+ * second switch: two switches on one row invites somebody to flip the wrong
+ * one, and these two do very different things.
+ */}
+
+
+ setRole.mutate({
+ userId: person.id,
+ role: checked ? "admin" : "user",
+ })
+ }
+ />
+
+
+ {index !== (people.data?.length ?? 0) - 1 && }
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/app/src/routes/sign.tsx b/app/src/routes/sign.tsx
index 8eb42d5..5f656e8 100644
--- a/app/src/routes/sign.tsx
+++ b/app/src/routes/sign.tsx
@@ -1,11 +1,23 @@
+import { useQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { motion, useReducedMotion } from "motion/react";
import { useState } from "react";
+import AgentOrb from "@/components/agents/orb/agent-orb";
+import { ProviderLogo } from "@/components/auth/provider-logo";
import { Button } from "@/components/ui/button";
-import { signInWithGoogle } from "@/lib/auth/client";
+import { Input } from "@/components/ui/input";
+import { Separator } from "@/components/ui/separator";
+import {
+ providerName,
+ signInWith,
+ signInWithEmailDomain,
+} from "@/lib/auth/client";
import { appConfig } from "@/lib/generated/application-config";
-import { currentUserQueryOptions } from "../lib/auth/queries";
-import AgentOrb from "@/components/agents/orb/agent-orb";
+import {
+ type AuthProviderId,
+ authProvidersQueryOptions,
+ currentUserQueryOptions,
+} from "../lib/auth/queries";
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
@@ -21,27 +33,58 @@ export const Route = createFileRoute("/sign")({
if (user) {
throw redirect({ to: "/" });
}
+ // Loaded here so the screen paints with its buttons rather than painting empty and then
+ // growing them, which reads as "no providers" for exactly as long as the request takes.
+ await context.queryClient.ensureQueryData(authProvidersQueryOptions());
},
component: SignScreen,
});
function SignScreen() {
- const [isPending, setIsPending] = useState(false);
+ // Which provider is being opened, rather than whether one is: with three buttons, a single
+ // boolean would put "Opening…" on all of them.
+ const [opening, setOpening] = useState(null);
const [error, setError] = useState(null);
+ const { data: options } = useQuery(authProvidersQueryOptions());
+ const providers = options?.providers ?? [];
+ const [email, setEmail] = useState("");
+
+ /**
+ * Sign in through whichever identity provider covers this address.
+ *
+ * No password is asked for and none is checked here: only the part after the @ is used, to decide
+ * which registered provider to hand somebody to.
+ */
+ async function handleDomainSignIn(submission: React.FormEvent) {
+ submission.preventDefault();
+ setError(null);
+ setOpening("sso");
+
+ try {
+ await signInWithEmailDomain(email);
+ } catch (caughtError) {
+ setError(
+ caughtError instanceof Error
+ ? caughtError.message
+ : "No identity provider is registered for that address.",
+ );
+ setOpening(null);
+ }
+ }
- async function handleGoogleSignIn() {
+ async function handleSignIn(provider: AuthProviderId) {
setError(null);
- setIsPending(true);
+ setOpening(provider);
try {
- await signInWithGoogle();
+ await signInWith(provider);
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
- : "Could not start Google sign-in.",
+ : `Could not start ${providerName(provider)} sign-in.`,
);
- setIsPending(false);
+ setOpening(null);
}
}
@@ -85,20 +128,78 @@ function SignScreen() {
transition={{ duration: ENTRANCE_SECONDS, ease: EASE_OUT }}
variants={{ hidden, shown }}
>
- {appConfig.auth.providers.includes("google") ? (
-
- ) : (
+ {providers.length > 0 ? (
+
+ {providers.map((provider) => (
+ /*
+ * Every provider gets the same button, and it is the light-themed outline one
+ * rather than the app's filled primary. Google's guidelines require their button be
+ * at least as prominent as any other sign-in option and specify its fill and
+ * stroke, so making one provider the loud one would break that for the others. The
+ * same size and weight throughout is also the honest presentation: a deployment
+ * that configured three has three, and none of them is the recommended one.
+ */
+
+ ))}
+
+ ) : options?.sso ? null : (
- No auth providers are configured.
+ No sign-in provider is configured for this deployment.
)}
+ {/*
+ * The way in for a company that runs its own identity provider.
+ *
+ * Below the buttons, because a deployment with both has more people arriving through the
+ * buttons: the registered providers are for the companies whose IdP was added by hand.
+ */}
+ {options?.sso ? (
+
+ ) : null}
{error ? (
{error}
diff --git a/app/tests/auth-client.test.ts b/app/tests/auth-client.test.ts
index 94c4f3e..86a7ede 100644
--- a/app/tests/auth-client.test.ts
+++ b/app/tests/auth-client.test.ts
@@ -1,7 +1,83 @@
-import { expect, test } from "bun:test";
-import { authClient, signInWithGoogle } from "@/lib/auth/client";
+import { describe, expect, test } from "bun:test";
+import { providerName, signInWith } from "@/lib/auth/client";
-test("starts the Google social sign-in flow through the Better Auth client", () => {
- expect(authClient.signIn.social).toBeFunction();
- expect(signInWithGoogle).toBeFunction();
+/*
+ * A browser origin, which the sign-in call needs for its callback URL and this environment has no
+ * window to supply. Stubbed rather than designed around: where the browser sends somebody back to
+ * is the browser's own business, and threading it through as an argument would only move the same
+ * value to the caller.
+ */
+(globalThis as { window?: unknown }).window = {
+ location: { origin: "http://localhost:3010" },
+};
+
+/**
+ * Starting sign-in, for each provider a deployment can configure.
+ *
+ * The point of these is that all three go the same way. Okta is served by the generic OAuth plugin
+ * rather than as a named provider, and it would have been easy to give it its own call; it does not
+ * have one because the browser should not know which kind of provider it is asking for, so that a
+ * deployment can gain one without the app being rebuilt.
+ */
+describe("signInWith", () => {
+ test.each(["google", "microsoft", "okta"] as const)(
+ "starts %s through the same call",
+ async (provider) => {
+ const asked: string[] = [];
+
+ await signInWith(provider, async (input) => {
+ asked.push(input.provider);
+ return {};
+ });
+
+ expect(asked).toEqual([provider]);
+ },
+ );
+
+ test("sends the browser back where it started", async () => {
+ let callbackURL = "";
+
+ await signInWith("google", async (input) => {
+ callbackURL = input.callbackURL;
+ return {};
+ });
+
+ expect(callbackURL).toBe("http://localhost:3010");
+ });
+
+ test("throws what the client said when it refuses", async () => {
+ const refuse = async () => ({
+ error: { message: "That provider is not configured." },
+ });
+
+ expect(signInWith("okta", refuse)).rejects.toThrow(
+ "That provider is not configured.",
+ );
+ });
+
+ /**
+ * A refusal with nothing to say still has to name the provider.
+ *
+ * With one button this did not matter. With three, "Could not start sign-in" leaves somebody
+ * looking at three buttons with no idea which one failed.
+ */
+ test("names the provider when the client says nothing", async () => {
+ const refuse = async () => ({ error: {} });
+
+ expect(signInWith("microsoft", refuse)).rejects.toThrow("Microsoft");
+ });
+
+ test("resolves quietly when the redirect is under way", async () => {
+ expect(
+ signInWith("google", async () => ({ error: null })),
+ ).resolves.toBeUndefined();
+ });
+});
+
+describe("providerName", () => {
+ test("gives each provider the name people call it", () => {
+ expect(providerName("google")).toBe("Google");
+ expect(providerName("microsoft")).toBe("Microsoft");
+ expect(providerName("okta")).toBe("Okta");
+ });
});
diff --git a/bun.lock b/bun.lock
index 892f9ef..4e6384e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -19,6 +19,7 @@
"dependencies": {
"@ag-ui/core": "0.0.57",
"@base-ui/react": "^1.6.0",
+ "@better-auth/sso": "^1.7.1",
"@copilotkit/react-core": "1.68.3",
"@fontsource-variable/inter": "^5.3.0",
"@shadcn/react": "^0.3.0",
@@ -60,10 +61,11 @@
"version": "0.0.0",
"dependencies": {
"@ag-ui/client": "0.0.57",
- "@better-auth/drizzle-adapter": "^1.6.27",
+ "@better-auth/drizzle-adapter": "^1.7.1",
+ "@better-auth/sso": "^1.7.1",
"@copilotkit/runtime": "1.68.3",
"@modelcontextprotocol/sdk": "^1.30.0",
- "better-auth": "^1.6.27",
+ "better-auth": "^1.7.1",
"cel-js": "^0.8.2",
"drizzle-orm": "^0.45.2",
"hono": "^4.10.0",
@@ -125,6 +127,8 @@
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
+ "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="],
+
"@aws-sdk/core": ["@aws-sdk/core@3.977.8", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA=="],
@@ -243,6 +247,8 @@
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-ZiUcafQ85InAofcUjyGgCPjKLfQjXr9SvDmMjuFUW8oEbreA6C6GaFAEA77VuV2doZUQlzvQQ4gCoTmS28W92A=="],
+ "@better-auth/sso": ["@better-auth/sso@1.7.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.1", "better-call": "1.4.0" } }, "sha512-fkGNMO8W5uNJSHAlvSe4Gxm1NVTQT4JFS7JpCNIxUAl5UyskE1yFXc2NH0KjcvSbR1RfgrqUgUevOjOjcSUKcg=="],
+
"@better-auth/telemetry": ["@better-auth/telemetry@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-kLKjMfFlTbyt49DGeI9okHAsn0MtBZcMoQYKaEdgR0H3BHzqqyzePcQz/hxAmRgjB4p/6inise3zJwhX0sgXrQ=="],
"@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="],
@@ -469,6 +475,8 @@
"@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="],
+ "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -911,6 +919,10 @@
"@whatwg-node/server": ["@whatwg-node/server@0.11.0", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ=="],
+ "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
+
+ "@xmldom/xmldom": ["@xmldom/xmldom@0.9.11", "", {}, "sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg=="],
+
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
@@ -931,6 +943,8 @@
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
+ "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="],
+
"app": ["app@workspace:app"],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -941,6 +955,8 @@
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
+ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
+
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
@@ -1285,6 +1301,10 @@
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
+ "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="],
+
+ "fast-xml-parser": ["fast-xml-parser@5.11.0", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw=="],
+
"fastdom": ["fastdom@1.0.12", "", { "dependencies": { "strictdom": "^1.0.1" } }, "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
@@ -1479,6 +1499,8 @@
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
+ "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="],
+
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="],
@@ -1767,6 +1789,8 @@
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
+ "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="],
+
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1829,6 +1853,8 @@
"path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
+ "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
+
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
@@ -1997,6 +2023,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+ "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
@@ -2077,6 +2105,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+ "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="],
+
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@0.4.4", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg=="],
@@ -2101,6 +2131,10 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+ "tldts": ["tldts@7.4.10", "", { "dependencies": { "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog=="],
+
+ "tldts-core": ["tldts-core@7.4.10", "", {}, "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw=="],
+
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -2223,6 +2257,16 @@
"wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
+ "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
+
+ "xml-crypto": ["xml-crypto@6.1.2", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", "xpath": "^0.0.33" } }, "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w=="],
+
+ "xml-escape": ["xml-escape@1.1.0", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="],
+
+ "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
+
+ "xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
+
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -2259,6 +2303,10 @@
"@ai-sdk/provider-utils/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
+ "@authenio/xml-encryption/@xmldom/xmldom": ["@xmldom/xmldom@0.8.14", "", {}, "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ=="],
+
+ "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
+
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@copilotkit/a2ui-renderer/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -2521,6 +2569,8 @@
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
+ "samlify/@xmldom/xmldom": ["@xmldom/xmldom@0.8.14", "", {}, "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ=="],
+
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -2567,6 +2617,10 @@
"wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
+ "xml-crypto/@xmldom/xmldom": ["@xmldom/xmldom@0.8.14", "", {}, "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ=="],
+
+ "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
+
"@ag-ui/mcp-middleware/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@0.0.54", "", { "dependencies": { "zod": "^3.22.4" } }, "sha512-Ilx31OvRQaZfU7jSArGqz06JZKOsAt8zWiCPJljyp9zR6Tzl18oyfx8o6FsuGfAktGRe50GI9SCCxNXXysZwtA=="],
"@ag-ui/mcp-middleware/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@0.0.54", "", { "dependencies": { "@ag-ui/core": "0.0.54", "@ag-ui/proto": "0.0.54" } }, "sha512-0dPuE/eAeBRBDj/OOj5AW8SoP1r0dufmoOdrtKgmf+dlbVXKSNkDDHGrrvIWFPxwvPTWhHeN6wnsVUayWpUsGg=="],
diff --git a/docs/README.md b/docs/README.md
index 7be1ff6..cdbc641 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,5 +6,7 @@ Start with the root [README](../README.md), then use these references:
- [Configuration](configuration.md): environment variables and tenant package YAML.
- [Development](development.md): local setup, migrations, ports, and quality checks.
- [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration.
+- [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes.
+- [Releasing](releasing.md): how a release is proposed, reviewed and published.
Do not include credential values, customer data, transcripts, or local-only notes in public docs.
diff --git a/docs/architecture.md b/docs/architecture.md
index 4805a80..025527c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -147,7 +147,11 @@ Connector credentials are stored through the credential vault and referenced by
## Security boundaries
- Server routes enforce auth and roles; admin pages are backed by server-side administrator checks.
-- `OPENBOT_DEV_NO_AUTH=true` is local-only and is refused with `NODE_ENV=production`.
+- Sign-in is Google, Microsoft or Okta from the environment, plus SAML and OpenID Connect providers registered at runtime and routed by email domain. One resolver answers both questions a run asks about a person, whose threads these are and which Bots they may run, so the two can never disagree.
+- `INITIAL_ADMIN_EMAILS` is a floor: an address it names is made an administrator at every sign-in and cannot be demoted from the People screen. Everybody else's role is decided there, and every change writes an audit row.
+- Registering, changing or removing an identity provider is administrator-only. Better Auth's SSO plugin guards those routes with a session alone, which would let any signed-in person register a provider for a domain.
+- Removing somebody deletes their sessions and denies their address, because deleting the user row alone is not removal: the next sign-in through the provider recreates it.
+- With no identity provider configured, every request is one fixed administrator. That is refused with `NODE_ENV=production` unless `OPENBOT_SINGLE_USER=true` says it was meant.
- `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`.
- Credential plaintext is encrypted at rest, never returned by APIs, and redacted from audit events.
- Browser navigation allows `http` and `https`; cloud metadata addresses are refused under every configuration.
diff --git a/docs/configuration.md b/docs/configuration.md
index d5bca7a..83b5cfb 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -46,6 +46,20 @@ All four Intelligence values are required together. Missing any of them stops se
| `GOOGLE_GENERATIVE_AI_BASE_URL` | unset | Google-compatible endpoint that key is spent against. |
| `BOT_MODEL` | provider default from Bot code/env | Model used by the shipped Bots. |
| `BOT_RESPONSES_API` | `false` | Makes `agent-langgraph` use the OpenAI Responses API. |
+| `AGENT_STALL_TIMEOUT_MS` | unset (off) | How long a Bot's stream may produce nothing before the turn is ended for it. |
+| `AGENT_TOOL_TOKEN` | unset | The secret a framework Bot presents when it calls a granted tool back through this server. |
+| `APP_DIST_DIR` | unset | Where the built app is, when this process serves it. Set inside the container image; unset in development, where Vite serves the app. |
+
+**`AGENT_STALL_TIMEOUT_MS`** watches for the failure a Bot has that nothing else in the trail can
+show: a stream that stops producing anything. Every other audit row is something that happened, and
+this one is the absence of anything happening, which leaves no trace of its own. Ending the turn
+writes `agent.stream_stalled`. Unset or `0` switches it off and nothing is watched. `.env.example`
+ships `60000`, so a new clone has it on and an upgraded deployment does not acquire it unasked.
+
+**`AGENT_TOOL_TOKEN`** exists because a framework Bot runs its own loop in its own process and still
+may not reach a vendor directly. It calls the deployment that granted the tool, which is where the
+grant, the policy and the audit row live. Absent, no Bot may call tools back, and it is told so
+rather than quietly allowed.
## OpenAI-compatible endpoints
@@ -82,15 +96,34 @@ Two things are worth knowing before pointing a deployment at any gateway. Not ev
| Variable | Meaning |
| ---------------------------- | -------------------------------------------------------------------------------------- |
-| `OPENBOT_DEV_NO_AUTH` | Local-only fixed administrator when set to `true`. Refused with `NODE_ENV=production`. |
+| `OPENBOT_SINGLE_USER` | One fixed administrator and no sign-in. Only read when no identity provider is configured, and only needed where `NODE_ENV=production` would otherwise refuse to start. |
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client id. |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret. |
-| `BETTER_AUTH_SECRET` | At least 32 characters. Required with Google OAuth. |
-| `BETTER_AUTH_URL` | Public API server base URL. Required with Google OAuth. |
+| `MICROSOFT_OAUTH_CLIENT_ID` | Microsoft Entra ID application id. |
+| `MICROSOFT_OAUTH_CLIENT_SECRET` | Microsoft Entra ID client secret. |
+| `MICROSOFT_OAUTH_TENANT_ID` | Directory to admit. `common` by default, which admits personal accounts too; a GUID admits one directory. |
+| `OKTA_OAUTH_CLIENT_ID` | Okta client id. |
+| `OKTA_OAUTH_CLIENT_SECRET` | Okta client secret. |
+| `OKTA_OAUTH_ISSUER` | Which Okta, for example `https://example.okta.com/oauth2/default`. |
+| `BETTER_AUTH_SECRET` | At least 32 characters. Required with any provider. |
+| `BETTER_AUTH_URL` | Public API server base URL, where OAuth callbacks return. Required with any provider. |
| `TRUSTED_ORIGINS` | Comma-separated app origins accepted by the API. |
-| `INITIAL_ADMIN_EMAILS` | Comma-separated users seeded as administrators. |
+| `INITIAL_ADMIN_EMAILS` | Comma-separated administrators. **Required** with any provider. |
+
+**Any one provider turns sign-in on**, and several may be configured at once. Each provider's id and
+secret must be set together, Okta additionally needs its issuer, and any of them requires
+`BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` and `INITIAL_ADMIN_EMAILS`. Every incomplete combination is
+refused at start-up rather than at somebody's first attempt to sign in.
+
+`INITIAL_ADMIN_EMAILS` is required because nothing else grants the administrator role at first: an
+address it names becomes an administrator at every sign-in and cannot be demoted from the People
+screen, which is what guarantees a way back in. Everybody else's role is decided there instead.
+
+SAML and OpenID Connect providers are not configured here. They are registered while the deployment
+runs, under Admin → Identity providers, and routed by email domain.
-Google OAuth client id and secret must be configured together. If Google OAuth is configured, `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are also required.
+The redirect URI to register with each provider is `/api/auth/callback/`,
+where `` is `google`, `microsoft` or `okta`.
## Computer and supervisor
diff --git a/docs/coworkers.md b/docs/coworkers.md
index 47ab2fe..db1a7e6 100644
--- a/docs/coworkers.md
+++ b/docs/coworkers.md
@@ -54,9 +54,13 @@ Hiding is personal roster state. It removes the coworker from one user's list wi
Product-created coworkers use:
```dotenv
-MANAGED_AGENT_AG_UI_URL=http://localhost:4200/ag-ui
+MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui
```
+That is `agent-langgraph`, which runs a real framework and its own tool loop. The proof-of-concept on
+`4200` hand-writes the protocol and leaves the loop to whatever is watching, so it is a reference
+rather than something to build a deployment on.
+
The server requires this setting at startup. Package-provided agents use their own `agents.yaml` configuration.
## Register an external AG-UI agent
diff --git a/docs/deployment.md b/docs/deployment.md
index 47da5cf..d08c480 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -63,6 +63,7 @@ and the fix would not be available.
| Variable | |
| --- | --- |
| `DATABASE_URL` | PostgreSQL with the `vector` extension. Not needed with `EMBEDDED_POSTGRES=on` |
+| an identity provider | `GOOGLE_OAUTH_*`, `MICROSOFT_OAUTH_*` or `OKTA_OAUTH_*`, with `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET` and `INITIAL_ADMIN_EMAILS`. See the README |
| `EMBEDDED_POSTGRES` | `on` to run the database inside the container. Off by default |
| `KEY_ENCRYPTION_KEY` | base64 32 bytes. `openssl rand -base64 32`. The example key is refused in production |
| `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` | CopilotKit Intelligence. A free plan is available and it can be self-hosted |
@@ -73,9 +74,10 @@ and the fix would not be available.
`COMPUTER_TOKEN` is generated at start if you do not set one. Both processes that need it are inside
the container, so there is nothing to share it with.
-**Authentication is required.** `OPENBOT_DEV_NO_AUTH` is refused when `NODE_ENV=production`, which
-the image sets. A deployment anybody can reach needs Google sign-in configured, or every visitor is
-an administrator.
+**Authentication is required.** With no identity provider configured the image refuses to start,
+because `NODE_ENV=production` is set and a public URL where every visitor is an administrator fails
+silently. Configure Google, Microsoft or Okta, or set `OPENBOT_SINGLE_USER=true` to say you meant an
+open deployment.
**Put TLS in front of it.** Not only for the cookies. A page served from `http://` is not a
secure context, which removes a set of browser APIs that are present on `http://localhost` and so
diff --git a/docs/development.md b/docs/development.md
index 91faa30..66d8b9d 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -56,6 +56,30 @@ bun run --filter server db:migrate
Review generated migration files before sharing them. `start.sh` applies existing migrations when it starts the stack.
+**Do not hand-edit a generated migration.** It leaves a file that no longer matches what the
+generator produced. If the generated SQL will not work — `ADD COLUMN ... NOT NULL` fails on a table
+that already has rows — split it instead: generate the column nullable, add the data step, then
+generate the constraint.
+
+**A data step is its own migration**, created with the flag that exists for it:
+
+```sh
+bun run --filter server db:generate -- --custom --name=backfill_something
+```
+
+A generator diffs schema against schema, so a rule like "the rows whose provider is Google get
+Google's issuer" can never come out of one: it is not in the schema. `--custom` writes an empty file
+registered in the journal, and it is the only migration anybody should be writing by hand.
+
+CI enforces two things about this: `drizzle-kit check` for collisions and gaps between migrations,
+and a generate-and-fail-if-dirty probe that refuses a schema change with no migration written for it.
+
+**If `drizzle-kit migrate` hangs and then exits non-zero with no error**, the journal names a
+migration file that is not there. A rebase does this: `meta/_journal.json` is a checked-in file, so
+restoring it can reinstate entries for migrations that were renamed. `drizzle-kit check` reports
+"Everything's fine" in that state, because it compares schemas rather than checking that the journal
+and the directory agree. Compare `meta/_journal.json` against `ls server/drizzle/*.sql`.
+
## Quality checks
Run these before opening a pull request:
diff --git a/scripts/generate-app-config.ts b/scripts/generate-app-config.ts
index 08fc2d5..103b7dc 100644
--- a/scripts/generate-app-config.ts
+++ b/scripts/generate-app-config.ts
@@ -13,17 +13,7 @@ const tenantPackageDirectory = configuredTenantPackageDirectory
: resolve(projectRoot, "server", configuredTenantPackageDirectory)
: resolve(projectRoot, "examples/fintech");
const tenantPackage = await loadTenantPackage(tenantPackageDirectory);
-const providers =
- process.env.GOOGLE_OAUTH_CLIENT_ID?.trim() &&
- process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim() &&
- process.env.BETTER_AUTH_SECRET?.trim() &&
- process.env.BETTER_AUTH_URL?.trim()
- ? ["google"]
- : [];
-const applicationConfiguration = createApplicationConfiguration(
- tenantPackage,
- providers,
-);
+const applicationConfiguration = createApplicationConfiguration(tenantPackage);
const outputPath = resolve(
projectRoot,
"app/src/lib/generated/application-config.ts",
@@ -34,7 +24,7 @@ await writeFile(
outputPath,
[
"// This file is generated by scripts/generate-app-config.ts. Do not edit it.",
- "export type AppConfig = { brand: { tenantId: string; productName: string }; auth: { providers: readonly string[] } };",
+ "export type AppConfig = { brand: { tenantId: string; productName: string } };",
`export const appConfig: AppConfig = ${JSON.stringify(applicationConfiguration, null, 2)};`,
"",
].join("\n"),
diff --git a/server/drizzle/0002_sign_in.sql b/server/drizzle/0002_sign_in.sql
new file mode 100644
index 0000000..931e463
--- /dev/null
+++ b/server/drizzle/0002_sign_in.sql
@@ -0,0 +1,20 @@
+CREATE TABLE "revoked_access" (
+ "email" text PRIMARY KEY NOT NULL,
+ "revoked_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "revoked_by" text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "sso_providers" (
+ "id" text PRIMARY KEY NOT NULL,
+ "issuer" text NOT NULL,
+ "oidc_config" text,
+ "saml_config" text,
+ "user_id" text,
+ "provider_id" text NOT NULL,
+ "organization_id" text,
+ "domain" text NOT NULL,
+ CONSTRAINT "sso_providers_provider_id_unique" UNIQUE("provider_id")
+);
+--> statement-breakpoint
+ALTER TABLE "accounts" ADD COLUMN "issuer" text;--> statement-breakpoint
+ALTER TABLE "sso_providers" ADD CONSTRAINT "sso_providers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/server/drizzle/0003_backfill_account_issuer.sql b/server/drizzle/0003_backfill_account_issuer.sql
new file mode 100644
index 0000000..1b513bb
--- /dev/null
+++ b/server/drizzle/0003_backfill_account_issuer.sql
@@ -0,0 +1,21 @@
+-- Give every account that already exists the issuer Better Auth 1.7 now requires.
+--
+-- Written rather than generated, through `drizzle-kit generate --custom`, which is Drizzle's own
+-- mechanism for exactly this. A generator diffs schema against schema, and "the rows whose provider
+-- is Google get Google's issuer" is not in the schema, so no diff can produce it. The two steps
+-- either side of this one are generated and their snapshots track real states.
+--
+-- The generatable alternative is a column default, and it is wrong: every existing Google account
+-- would take the placeholder, stop matching `https://accounts.google.com` at that person's next
+-- sign-in, and Better Auth would create a second account for the same person. A tidier file is not
+-- worth that.
+--
+-- Anything that is not Google or a local credential gets the synthetic form Better Auth mints for a
+-- provider with no issuer of its own, which is the same string it would have written itself.
+UPDATE "accounts"
+SET "issuer" = CASE
+ WHEN "provider_id" = 'google' THEN 'https://accounts.google.com'
+ WHEN "provider_id" = 'credential' THEN 'local:credential'
+ ELSE 'local:oauth:' || "provider_id"
+END
+WHERE "issuer" IS NULL;
diff --git a/server/drizzle/0004_account_issuer_required.sql b/server/drizzle/0004_account_issuer_required.sql
new file mode 100644
index 0000000..c4503b5
--- /dev/null
+++ b/server/drizzle/0004_account_issuer_required.sql
@@ -0,0 +1 @@
+ALTER TABLE "accounts" ALTER COLUMN "issuer" SET NOT NULL;
\ No newline at end of file
diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..101c039
--- /dev/null
+++ b/server/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,2630 @@
+{
+ "id": "93c2dd26-6083-45da-b279-0d0c0e1b25f4",
+ "prevId": "3fd9e9ec-f351-4dbc-ad12-7d887be8d0ce",
+ "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.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
+ },
+ "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": {}
+ }
+ },
+ "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
+ },
+ "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
+ },
+ "last_message": {
+ "name": "last_message",
+ "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
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time 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": {}
+ }
+ },
+ "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.chunks": {
+ "name": "chunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chunks_document_position_idx": {
+ "name": "chunks_document_position_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chunks_document_idx": {
+ "name": "chunks_document_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chunks_document_id_documents_id_fk": {
+ "name": "chunks_document_id_documents_id_fk",
+ "tableFrom": "chunks",
+ "tableTo": "documents",
+ "columnsFrom": ["document_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_cursors": {
+ "name": "connector_cursors",
+ "schema": "",
+ "columns": {
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cursor": {
+ "name": "cursor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "connector_cursors_connector_instance_id_connector_instances_id_fk": {
+ "name": "connector_cursors_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "connector_cursors",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_instances": {
+ "name": "connector_instances",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "connector_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "source_metadata": {
+ "name": "source_metadata",
+ "type": "jsonb",
+ "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": {
+ "connector_instances_credential_id_credentials_id_fk": {
+ "name": "connector_instances_credential_id_credentials_id_fk",
+ "tableFrom": "connector_instances",
+ "tableTo": "credentials",
+ "columnsFrom": ["credential_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": {},
+ "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.document_acls": {
+ "name": "document_acls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal": {
+ "name": "principal",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect": {
+ "name": "effect",
+ "type": "acl_effect",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_acls_document_principal_effect_idx": {
+ "name": "document_acls_document_principal_effect_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "effect",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_acls_principal_idx": {
+ "name": "document_acls_principal_idx",
+ "columns": [
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_acls_document_id_documents_id_fk": {
+ "name": "document_acls_document_id_documents_id_fk",
+ "tableFrom": "document_acls",
+ "tableTo": "documents",
+ "columnsFrom": ["document_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.documents": {
+ "name": "documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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": {
+ "documents_connector_source_idx": {
+ "name": "documents_connector_source_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "documents_connector_deleted_idx": {
+ "name": "documents_connector_deleted_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "documents_connector_instance_id_connector_instances_id_fk": {
+ "name": "documents_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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": "cascade",
+ "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.sync_runs": {
+ "name": "sync_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stats": {
+ "name": "stats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sync_runs_connector_started_at_idx": {
+ "name": "sync_runs_connector_started_at_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sync_runs_connector_instance_id_connector_instances_id_fk": {
+ "name": "sync_runs_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "sync_runs",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_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
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time 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.webhook_subscriptions": {
+ "name": "webhook_subscriptions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_subscription_id": {
+ "name": "provider_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_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": {
+ "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": {
+ "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "webhook_subscriptions",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "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.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
+ }
+ },
+ "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.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.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
+ },
+ "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": "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": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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.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.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
+ }
+ },
+ "enums": {
+ "public.acl_effect": {
+ "name": "acl_effect",
+ "schema": "public",
+ "values": ["allow", "deny"]
+ },
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": ["built_in", "remote_ag_ui"]
+ },
+ "public.connector_type": {
+ "name": "connector_type",
+ "schema": "public",
+ "values": ["google_drive", "onedrive"]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": ["model", "connector", "agent", "mcp"]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": ["admin", "user"]
+ },
+ "public.sync_status": {
+ "name": "sync_status",
+ "schema": "public",
+ "values": ["pending", "running", "succeeded", "failed"]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": ["public", "private"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/drizzle/meta/0003_snapshot.json b/server/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..5e57ccb
--- /dev/null
+++ b/server/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,2630 @@
+{
+ "id": "09dc7c9c-6132-4413-a291-72aee0a30b2c",
+ "prevId": "93c2dd26-6083-45da-b279-0d0c0e1b25f4",
+ "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,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "columnsFrom": ["user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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",
+ "columnsFrom": ["package_id"],
+ "tableTo": "deployment_packages",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "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
+ },
+ "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,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "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",
+ "columnsFrom": ["channel_id"],
+ "tableTo": "channels",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "channel_agents_agent_id_agents_id_fk": {
+ "name": "channel_agents_agent_id_agents_id_fk",
+ "tableFrom": "channel_agents",
+ "columnsFrom": ["agent_id"],
+ "tableTo": "agents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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
+ },
+ "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",
+ "columnsFrom": ["channel_id"],
+ "tableTo": "channels",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "channel_memberships_user_id_users_id_fk": {
+ "name": "channel_memberships_user_id_users_id_fk",
+ "tableFrom": "channel_memberships",
+ "columnsFrom": ["user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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
+ },
+ "last_message": {
+ "name": "last_message",
+ "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
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time 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",
+ "isExpression": true,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "channels_package_id_deployment_packages_id_fk": {
+ "name": "channels_package_id_deployment_packages_id_fk",
+ "tableFrom": "channels",
+ "columnsFrom": ["package_id"],
+ "tableTo": "deployment_packages",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ },
+ "channels_last_message_agent_id_agents_id_fk": {
+ "name": "channels_last_message_agent_id_agents_id_fk",
+ "tableFrom": "channels",
+ "columnsFrom": ["last_message_agent_id"],
+ "tableTo": "agents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chunks": {
+ "name": "chunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chunks_document_position_idx": {
+ "name": "chunks_document_position_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "chunks_document_idx": {
+ "name": "chunks_document_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "chunks_document_id_documents_id_fk": {
+ "name": "chunks_document_id_documents_id_fk",
+ "tableFrom": "chunks",
+ "columnsFrom": ["document_id"],
+ "tableTo": "documents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_cursors": {
+ "name": "connector_cursors",
+ "schema": "",
+ "columns": {
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cursor": {
+ "name": "cursor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "connector_cursors_connector_instance_id_connector_instances_id_fk": {
+ "name": "connector_cursors_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "connector_cursors",
+ "columnsFrom": ["connector_instance_id"],
+ "tableTo": "connector_instances",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_instances": {
+ "name": "connector_instances",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "connector_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "source_metadata": {
+ "name": "source_metadata",
+ "type": "jsonb",
+ "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": {
+ "connector_instances_credential_id_credentials_id_fk": {
+ "name": "connector_instances_credential_id_credentials_id_fk",
+ "tableFrom": "connector_instances",
+ "columnsFrom": ["credential_id"],
+ "tableTo": "credentials",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "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": {},
+ "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",
+ "columns": ["tenant_id"],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_acls": {
+ "name": "document_acls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal": {
+ "name": "principal",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect": {
+ "name": "effect",
+ "type": "acl_effect",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_acls_document_principal_effect_idx": {
+ "name": "document_acls_document_principal_effect_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "effect",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "document_acls_principal_idx": {
+ "name": "document_acls_principal_idx",
+ "columns": [
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "document_acls_document_id_documents_id_fk": {
+ "name": "document_acls_document_id_documents_id_fk",
+ "tableFrom": "document_acls",
+ "columnsFrom": ["document_id"],
+ "tableTo": "documents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.documents": {
+ "name": "documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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": {
+ "documents_connector_source_idx": {
+ "name": "documents_connector_source_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "documents_connector_deleted_idx": {
+ "name": "documents_connector_deleted_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "documents_connector_instance_id_connector_instances_id_fk": {
+ "name": "documents_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "documents",
+ "columnsFrom": ["connector_instance_id"],
+ "tableTo": "connector_instances",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "intelligence_channel_mappings_user_id_users_id_fk": {
+ "name": "intelligence_channel_mappings_user_id_users_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "columnsFrom": ["user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "intelligence_channel_mappings_channel_id_channels_id_fk": {
+ "name": "intelligence_channel_mappings_channel_id_channels_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "columnsFrom": ["channel_id"],
+ "tableTo": "channels",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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",
+ "columnsFrom": ["user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "columns": ["token"],
+ "nullsNotDistinct": false
+ }
+ },
+ "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",
+ "columnsFrom": ["user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_providers_provider_id_unique": {
+ "name": "sso_providers_provider_id_unique",
+ "columns": ["provider_id"],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sync_runs": {
+ "name": "sync_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stats": {
+ "name": "stats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sync_runs_connector_started_at_idx": {
+ "name": "sync_runs_connector_started_at_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "sync_runs_connector_instance_id_connector_instances_id_fk": {
+ "name": "sync_runs_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "sync_runs",
+ "columnsFrom": ["connector_instance_id"],
+ "tableTo": "connector_instances",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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",
+ "columnsFrom": ["user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time 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",
+ "columns": ["email"],
+ "nullsNotDistinct": false
+ }
+ },
+ "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.webhook_subscriptions": {
+ "name": "webhook_subscriptions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_subscription_id": {
+ "name": "provider_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_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": {
+ "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": {
+ "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "webhook_subscriptions",
+ "columnsFrom": ["connector_instance_id"],
+ "tableTo": "connector_instances",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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.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
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_preferences_user_id_users_id_fk": {
+ "name": "agent_preferences_user_id_users_id_fk",
+ "tableFrom": "agent_preferences",
+ "columnsFrom": ["user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "agent_preferences_agent_id_agents_id_fk": {
+ "name": "agent_preferences_agent_id_agents_id_fk",
+ "tableFrom": "agent_preferences",
+ "columnsFrom": ["agent_id"],
+ "tableTo": "agents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "agent_profiles_agent_id_agents_id_fk": {
+ "name": "agent_profiles_agent_id_agents_id_fk",
+ "tableFrom": "agent_profiles",
+ "columnsFrom": ["agent_id"],
+ "tableTo": "agents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "agent_profiles_owner_user_id_users_id_fk": {
+ "name": "agent_profiles_owner_user_id_users_id_fk",
+ "tableFrom": "agent_profiles",
+ "columnsFrom": ["owner_user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "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",
+ "columnsFrom": ["component_name"],
+ "tableTo": "components",
+ "columnsTo": ["name"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "component_exclusions_agent_id_agents_id_fk": {
+ "name": "component_exclusions_agent_id_agents_id_fk",
+ "tableFrom": "component_exclusions",
+ "columnsFrom": ["agent_id"],
+ "tableTo": "agents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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",
+ "columnsFrom": ["component_name"],
+ "tableTo": "components",
+ "columnsTo": ["name"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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.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
+ },
+ "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": "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": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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",
+ "columnsFrom": ["server_id"],
+ "tableTo": "mcp_servers",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_tools_server_id_name_pk": {
+ "name": "mcp_tools_server_id_name_pk",
+ "columns": ["server_id", "name"]
+ }
+ },
+ "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,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "plugin_grants_agent_id_agents_id_fk": {
+ "name": "plugin_grants_agent_id_agents_id_fk",
+ "tableFrom": "plugin_grants",
+ "columnsFrom": ["agent_id"],
+ "tableTo": "agents",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "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.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,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "skills_owner_idx": {
+ "name": "skills_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "skills_owner_user_id_users_id_fk": {
+ "name": "skills_owner_user_id_users_id_fk",
+ "tableFrom": "skills",
+ "columnsFrom": ["owner_user_id"],
+ "tableTo": "users",
+ "columnsTo": ["id"],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.acl_effect": {
+ "name": "acl_effect",
+ "schema": "public",
+ "values": ["allow", "deny"]
+ },
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": ["built_in", "remote_ag_ui"]
+ },
+ "public.connector_type": {
+ "name": "connector_type",
+ "schema": "public",
+ "values": ["google_drive", "onedrive"]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": ["model", "connector", "agent", "mcp"]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": ["admin", "user"]
+ },
+ "public.sync_status": {
+ "name": "sync_status",
+ "schema": "public",
+ "values": ["pending", "running", "succeeded", "failed"]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": ["public", "private"]
+ }
+ },
+ "schemas": {},
+ "views": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/drizzle/meta/0004_snapshot.json b/server/drizzle/meta/0004_snapshot.json
new file mode 100644
index 0000000..6c7a300
--- /dev/null
+++ b/server/drizzle/meta/0004_snapshot.json
@@ -0,0 +1,2630 @@
+{
+ "id": "ab829598-5c7b-4ed7-9641-79d8dbe2bd19",
+ "prevId": "09dc7c9c-6132-4413-a291-72aee0a30b2c",
+ "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": true
+ },
+ "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.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
+ },
+ "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": {}
+ }
+ },
+ "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
+ },
+ "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
+ },
+ "last_message": {
+ "name": "last_message",
+ "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
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time 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": {}
+ }
+ },
+ "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.chunks": {
+ "name": "chunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chunks_document_position_idx": {
+ "name": "chunks_document_position_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chunks_document_idx": {
+ "name": "chunks_document_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chunks_document_id_documents_id_fk": {
+ "name": "chunks_document_id_documents_id_fk",
+ "tableFrom": "chunks",
+ "tableTo": "documents",
+ "columnsFrom": ["document_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_cursors": {
+ "name": "connector_cursors",
+ "schema": "",
+ "columns": {
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cursor": {
+ "name": "cursor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "connector_cursors_connector_instance_id_connector_instances_id_fk": {
+ "name": "connector_cursors_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "connector_cursors",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_instances": {
+ "name": "connector_instances",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "connector_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "source_metadata": {
+ "name": "source_metadata",
+ "type": "jsonb",
+ "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": {
+ "connector_instances_credential_id_credentials_id_fk": {
+ "name": "connector_instances_credential_id_credentials_id_fk",
+ "tableFrom": "connector_instances",
+ "tableTo": "credentials",
+ "columnsFrom": ["credential_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": {},
+ "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.document_acls": {
+ "name": "document_acls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal": {
+ "name": "principal",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect": {
+ "name": "effect",
+ "type": "acl_effect",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_acls_document_principal_effect_idx": {
+ "name": "document_acls_document_principal_effect_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "effect",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_acls_principal_idx": {
+ "name": "document_acls_principal_idx",
+ "columns": [
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_acls_document_id_documents_id_fk": {
+ "name": "document_acls_document_id_documents_id_fk",
+ "tableFrom": "document_acls",
+ "tableTo": "documents",
+ "columnsFrom": ["document_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.documents": {
+ "name": "documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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": {
+ "documents_connector_source_idx": {
+ "name": "documents_connector_source_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "documents_connector_deleted_idx": {
+ "name": "documents_connector_deleted_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "documents_connector_instance_id_connector_instances_id_fk": {
+ "name": "documents_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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": "cascade",
+ "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.sync_runs": {
+ "name": "sync_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stats": {
+ "name": "stats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sync_runs_connector_started_at_idx": {
+ "name": "sync_runs_connector_started_at_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sync_runs_connector_instance_id_connector_instances_id_fk": {
+ "name": "sync_runs_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "sync_runs",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_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
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time 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.webhook_subscriptions": {
+ "name": "webhook_subscriptions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_subscription_id": {
+ "name": "provider_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_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": {
+ "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": {
+ "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "webhook_subscriptions",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "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.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
+ }
+ },
+ "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.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.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
+ },
+ "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": "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": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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.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.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
+ }
+ },
+ "enums": {
+ "public.acl_effect": {
+ "name": "acl_effect",
+ "schema": "public",
+ "values": ["allow", "deny"]
+ },
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": ["built_in", "remote_ag_ui"]
+ },
+ "public.connector_type": {
+ "name": "connector_type",
+ "schema": "public",
+ "values": ["google_drive", "onedrive"]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": ["model", "connector", "agent", "mcp"]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": ["admin", "user"]
+ },
+ "public.sync_status": {
+ "name": "sync_status",
+ "schema": "public",
+ "values": ["pending", "running", "succeeded", "failed"]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": ["public", "private"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json
index 64c4fbf..86ff22e 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -15,6 +15,27 @@
"when": 1787198911059,
"tag": "0001_swift_morph",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1787275307382,
+ "tag": "0002_sign_in",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1787275307621,
+ "tag": "0003_backfill_account_issuer",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1787275307878,
+ "tag": "0004_account_issuer_required",
+ "breakpoints": true
}
]
}
diff --git a/server/package.json b/server/package.json
index 43b1c35..768acd4 100644
--- a/server/package.json
+++ b/server/package.json
@@ -13,10 +13,11 @@
},
"dependencies": {
"@ag-ui/client": "0.0.57",
- "@better-auth/drizzle-adapter": "^1.6.27",
+ "@better-auth/drizzle-adapter": "^1.7.1",
+ "@better-auth/sso": "^1.7.1",
"@copilotkit/runtime": "1.68.3",
"@modelcontextprotocol/sdk": "^1.30.0",
- "better-auth": "^1.6.27",
+ "better-auth": "^1.7.1",
"cel-js": "^0.8.2",
"drizzle-orm": "^0.45.2",
"hono": "^4.10.0",
diff --git a/server/src/app.ts b/server/src/app.ts
index 7fef8fe..1f949d2 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -1,8 +1,15 @@
import type { Hono as HonoApp, MiddlewareHandler } from "hono";
import { Hono } from "hono";
+import { serveStatic } from "hono/bun";
+import { authoriseAgentCall } from "./agents/callback-token";
import type { AgentProfileStore } from "./agents/profile-store";
import { createAgentRoutes } from "./agents/routes";
-import { type AuditReader, type AuditStore, auditQueryFromUrl } from "./audit";
+import {
+ type AuditReader,
+ type AuditStore,
+ auditQueryFromUrl,
+ recordAuditEvent,
+} from "./audit";
import { createDevRequireUser } from "./auth/dev-actor";
import {
type AppVariables,
@@ -22,16 +29,41 @@ import type { ComponentStore } from "./components/store";
import type { ComputerGateway } from "./computer/gateway";
import type { PolicyStore } from "./computer/policy-store";
import { createComputerRoutes } from "./computer/routes";
-import { authoriseAgentCall } from "./agents/callback-token";
-import type { DeploymentConfig } from "./config";
+import { configuredAuthProviders, type DeploymentConfig } from "./config";
import type { ConnectorAdminService } from "./connectors";
import type { CredentialAdminService, CredentialInput } from "./credentials";
-import { serveStatic } from "hono/bun";
+import type { PeopleStore } from "./people/store";
import { createPluginRoutes } from "./plugins/routes";
-import { REFUSAL_MARKER } from "./plugins/tools";
import type { PluginStore } from "./plugins/store";
+import { REFUSAL_MARKER } from "./plugins/tools";
import type { PackageStatusReader } from "./tenant-package";
+/**
+ * One row for something an administrator did to somebody's access.
+ *
+ * The address is on the row rather than only the user id, because the id means nothing to a person
+ * reading the trail a year later and the user row may be gone by then.
+ */
+async function recordPersonEvent(
+ auditStore: AuditStore | undefined,
+ context: { var: AppVariables },
+ eventType:
+ | "person.role_changed"
+ | "person.access_revoked"
+ | "person.access_restored",
+ person: { id: string; email: string },
+ payload: Record,
+) {
+ if (!auditStore) return;
+ await recordAuditEvent(auditStore, {
+ eventType,
+ targetType: "person",
+ targetId: person.id,
+ actorUserId: context.var.actor.id,
+ payload: { email: person.email, ...payload },
+ });
+}
+
export function createApp(
config: DeploymentConfig,
auth?: AuthService,
@@ -95,6 +127,21 @@ export function createApp(
* says nothing about which deployment the conversation belongs to.
*/
threadIdentity?: ThreadIdentity,
+ /**
+ * Who has signed in, and what an administrator may do about them.
+ *
+ * Absent leaves the people screen answering 503 rather than an empty list, which is the honest
+ * degraded behaviour: "nobody has signed in" and "this deployment cannot tell you" are different
+ * answers and an administrator deciding who has access needs to know which one they are reading.
+ */
+ peopleStore?: PeopleStore,
+ /**
+ * How many enterprise identity providers are registered.
+ *
+ * A count rather than the store, because the only question anybody outside Better Auth asks is
+ * whether the sign-in screen should offer the email box at all.
+ */
+ ssoProviderCount?: () => Promise,
) {
const app = new Hono<{ Variables: AppVariables }>();
@@ -102,31 +149,80 @@ export function createApp(
// Projected, never the raw runtime. config.runtime carries the Intelligence contract, including
// INTELLIGENCE_API_KEY and the licence token, and this endpoint is reachable by anyone. Returning
// the object wholesale would serve deployment secrets to the browser. Add fields here explicitly.
- app.get("/api/capabilities", (context) =>
+ app.get("/api/capabilities", async (context) =>
context.json({
mode: config.runtime.mode,
durableHistory: config.runtime.durableHistory,
+ /*
+ * Which identity providers this deployment can sign somebody in with.
+ *
+ * Ids only, never the credentials: `configuredAuthProviders` returns names, and the clients
+ * and secrets behind them stay in `config.auth`, which is not projected here.
+ *
+ * Answered at runtime rather than baked into the build, because the container image is built
+ * once and knows nothing about the deployment that will run it. A sign-in screen compiled on a
+ * build machine cannot offer a provider that machine had never heard of.
+ */
+ authProviders: configuredAuthProviders(config.auth),
+ /*
+ * Whether any enterprise identity provider has been registered.
+ *
+ * A count, not a list. The sign-in screen only needs to know whether to offer the email box
+ * that routes by domain; naming the providers would tell anybody who loads the page which
+ * companies use this deployment, which is not theirs to have before they sign in.
+ */
+ ssoConfigured: (await ssoProviderCount?.()) ? true : false,
}),
);
- app.on(["GET", "POST"], "/api/auth/*", (context) => {
+ /*
+ * Registering an identity provider is an administrator's decision, not a signed-in one.
+ *
+ * Better Auth's SSO plugin guards these with `sessionMiddleware`, which asks only that somebody is
+ * signed in. That is the wrong bar here: registering an IdP for a domain means anybody it vouches
+ * for can sign in, so a plain user reaching this could mint themselves colleagues. The routes are
+ * mounted through this handler, so the check goes in front of it.
+ */
+ const ADMIN_ONLY_AUTH_ROUTES = new Set([
+ "/api/auth/sso/register",
+ "/api/auth/sso/update-provider",
+ "/api/auth/sso/delete-provider",
+ ]);
+
+ app.on(["GET", "POST"], "/api/auth/*", async (context) => {
if (!auth) {
return context.json(
- { error: "Google authentication is not configured." },
+ { error: "No identity provider is configured." },
503,
);
}
+ if (ADMIN_ONLY_AUTH_ROUTES.has(new URL(context.req.url).pathname)) {
+ const session = await auth.api.getSession({
+ headers: context.req.raw.headers,
+ // Fresh, not the cookie cache: a role changed a moment ago has to apply to this request.
+ query: { disableCookieCache: true },
+ });
+ const roles = session?.user
+ ? ((await roleRepository?.rolesForUser(session.user.id)) ?? [])
+ : [];
+ if (!roles.includes("admin")) {
+ return context.json(
+ { error: "Only an administrator may change identity providers." },
+ 403,
+ );
+ }
+ }
+
return auth.handler(context.req.raw);
});
const authenticationUnavailable: MiddlewareHandler<{
Variables: AppVariables;
}> = async (context) =>
- context.json({ error: "Google authentication is not configured." }, 503);
- // Local development can stand in a fixed administrator so the product is reachable before the
- // authentication slice is built. It is checked first so a machine with the flag set does not also
- // need Google credentials configured just to boot.
- const requireUser = config.devNoAuth
+ context.json({ error: "No identity provider is configured." }, 503);
+ // One administrator, when nothing is configured to sign anybody in. Checked first, and only ever
+ // true when there is no provider, so a configured deployment cannot fall back to it.
+ const requireUser = config.singleUser
? createDevRequireUser()
: auth && roleRepository
? createRequireUser(auth, roleRepository)
@@ -152,6 +248,154 @@ export function createApp(
await auditReader.list(auditQueryFromUrl(new URL(context.req.url))),
);
});
+ /*
+ * Who is here, and what they may do.
+ *
+ * Administrator-only, like every other route in this group. A plain user reading the list would
+ * learn every colleague's address and when they last signed in, which is not theirs to have.
+ */
+ app.get("/api/admin/people", requireUser, async (context) => {
+ const denied = requireAdmin(context);
+ if (denied) {
+ return denied;
+ }
+ if (!peopleStore) {
+ return context.json({ error: "People are not available." }, 503);
+ }
+
+ return context.json({ people: await peopleStore.list() });
+ });
+
+ app.post("/api/admin/people/:userId/role", requireUser, async (context) => {
+ const denied = requireAdmin(context);
+ if (denied) {
+ return denied;
+ }
+ if (!peopleStore) {
+ return context.json({ error: "People are not available." }, 503);
+ }
+
+ const body = await context.req.json().catch(() => null);
+ const role = (body as { role?: unknown } | null)?.role;
+ if (role !== "admin" && role !== "user") {
+ return context.json(
+ { error: "A role of admin or user is required." },
+ 400,
+ );
+ }
+
+ const userId = context.req.param("userId");
+ const person = await peopleStore.find(userId);
+ if (!person) {
+ return context.json({ error: "That person is not here." }, 404);
+ }
+
+ /*
+ * The configured floor wins over the screen.
+ *
+ * Somebody named in INITIAL_ADMIN_EMAILS is promoted again at their next sign-in whatever this
+ * route writes, so allowing the demotion would produce a screen that lies until they come back.
+ * Refusing says the real thing: change the deployment's configuration.
+ */
+ if (person.configuredAdmin && role !== "admin") {
+ return context.json(
+ {
+ error:
+ "This deployment names that address in INITIAL_ADMIN_EMAILS, so they stay an administrator. Change the configuration instead.",
+ },
+ 409,
+ );
+ }
+
+ /*
+ * Nobody demotes themselves.
+ *
+ * An administrator who does has just locked themselves out of the screen that would undo it,
+ * and on a deployment with one administrator that is the whole deployment. Somebody else with
+ * the role can do it, which is the check that makes handover possible without making lockout
+ * a slip of the finger.
+ */
+ if (context.var.actor.id === userId && role !== "admin") {
+ return context.json(
+ { error: "You cannot remove your own administrator role." },
+ 409,
+ );
+ }
+
+ if (person.role !== role) {
+ await peopleStore.setRole(userId, role);
+ await recordPersonEvent(
+ auditStore,
+ context,
+ "person.role_changed",
+ person,
+ {
+ from: person.role,
+ to: role,
+ },
+ );
+ }
+
+ return context.json({ person: await peopleStore.find(userId) });
+ });
+
+ app.post("/api/admin/people/:userId/access", requireUser, async (context) => {
+ const denied = requireAdmin(context);
+ if (denied) {
+ return denied;
+ }
+ if (!peopleStore) {
+ return context.json({ error: "People are not available." }, 503);
+ }
+
+ const body = await context.req.json().catch(() => null);
+ const revoked = (body as { revoked?: unknown } | null)?.revoked;
+ if (typeof revoked !== "boolean") {
+ return context.json({ error: "revoked must be true or false." }, 400);
+ }
+
+ const userId = context.req.param("userId");
+ const person = await peopleStore.find(userId);
+ if (!person) {
+ return context.json({ error: "That person is not here." }, 404);
+ }
+
+ // The same floor, for the same reason: removing somebody the configuration names would last
+ // until their next sign-in and no longer.
+ if (person.configuredAdmin && revoked) {
+ return context.json(
+ {
+ error:
+ "This deployment names that address in INITIAL_ADMIN_EMAILS, so they cannot be removed here. Change the configuration instead.",
+ },
+ 409,
+ );
+ }
+
+ // Nobody can remove themselves. An administrator who does has locked themselves out of the
+ // screen that would undo it.
+ if (revoked && context.var.actor.id === userId) {
+ return context.json({ error: "You cannot remove your own access." }, 409);
+ }
+
+ if (person.revoked !== revoked) {
+ if (revoked) {
+ await peopleStore.revoke(userId, context.var.actor.id);
+ } else {
+ await peopleStore.restore(userId);
+ }
+ await recordPersonEvent(
+ auditStore,
+ context,
+ revoked ? "person.access_revoked" : "person.access_restored",
+ person,
+ {},
+ );
+ }
+
+ return context.json({ person: await peopleStore.find(userId) });
+ });
+
app.get("/api/admin/credentials", requireUser, async (context) => {
const denied = requireAdmin(context);
if (denied) {
diff --git a/server/src/audit.ts b/server/src/audit.ts
index d72250e..7bf748a 100644
--- a/server/src/audit.ts
+++ b/server/src/audit.ts
@@ -155,6 +155,16 @@ export const auditEventTypes = [
"component.function_called",
"component.function_refused",
"component.function_failed",
+ /*
+ * Who may use this deployment, and at what level.
+ *
+ * On the trail rather than only in the table, because the table holds the current answer and this
+ * is the only place that says who changed it and when. "Why does this person have admin" and "who
+ * removed them" are questions a table of current state cannot answer at all.
+ */
+ "person.role_changed",
+ "person.access_revoked",
+ "person.access_restored",
] as const;
export type AuditEventType = (typeof auditEventTypes)[number];
diff --git a/server/src/auth/dev-actor.ts b/server/src/auth/dev-actor.ts
index e602336..c06e0bc 100644
--- a/server/src/auth/dev-actor.ts
+++ b/server/src/auth/dev-actor.ts
@@ -4,20 +4,18 @@ import { users } from "../db/schema";
import type { AppVariables, AuthenticatedActor } from "./guards";
/**
- * A signed-in person, without signing in. Local development only.
+ * A signed-in person, without signing in.
*
- * Local development can opt into a fixed administrator actor so the product can run without Google
- * OAuth credentials or an interactive consent screen. Hosted deployments must use real
- * authentication.
+ * A clone with no identity provider configured is one administrator, so `bun run dev` reaches the
+ * product without registering an OAuth client first. That is the whole point of it: nobody should
+ * have to set up Entra to look at a Bot.
*
- * Two independent locks:
+ * The lock is `NODE_ENV`, not a flag. Somewhere reachable by other people, an unconfigured
+ * deployment refuses to start and names what to configure, because a public URL where every visitor
+ * is an administrator is the failure this exists to prevent, and it is silent: it looks like it
+ * works. `OPENBOT_SINGLE_USER=true` is how somebody says they meant it anyway.
*
- * 1. `OPENBOT_DEV_NO_AUTH=true` must be set. Absent, nothing here runs.
- * 2. `NODE_ENV` must not be `production`. A deployment that sets the flag by accident still refuses,
- * and it refuses by refusing to start rather than by ignoring the flag, because a
- * deployment believing it has authentication when it does not is the worst of the three states.
- *
- * The actor is an administrator so admin surfaces can be demonstrated too, and its id is fixed so
+ * The actor is an administrator so admin surfaces can be reached too, and its id is fixed so
* Intelligence threads and memory stay attached to the same person across restarts.
*/
@@ -56,21 +54,37 @@ export async function initializeDevActorUser(
return true;
}
-export function devAuthEnabled(
+/**
+ * Whether this deployment admits everybody as one administrator.
+ *
+ * Only ever true when no identity provider is configured: a provider always wins, so a deployment
+ * cannot half sign people in.
+ *
+ * @param hasProvider whether any identity provider is configured
+ */
+export function singleUserEnabled(
environment: Record,
+ hasProvider: boolean,
): boolean {
- if (environment.OPENBOT_DEV_NO_AUTH?.trim() !== "true") {
- return false;
- }
+ if (hasProvider) return false;
+
+ // Said explicitly, which is the only way to run open where other people can reach it.
+ const asked =
+ environment.OPENBOT_SINGLE_USER?.trim() === "true" ||
+ // The name this had before. Still honoured so an existing .env keeps working.
+ environment.OPENBOT_DEV_NO_AUTH?.trim() === "true";
+ if (asked) return true;
+
if (environment.NODE_ENV === "production") {
throw new Error(
- "OPENBOT_DEV_NO_AUTH cannot be used with NODE_ENV=production. Refusing to start without authentication.",
+ "No identity provider is configured. Set GOOGLE_OAUTH_*, MICROSOFT_OAUTH_* or OKTA_OAUTH_* with BETTER_AUTH_SECRET and BETTER_AUTH_URL, or set OPENBOT_SINGLE_USER=true to run with one administrator and no sign-in. Refusing to start rather than serving an open deployment.",
);
}
+
return true;
}
-/** A guard that admits everybody as {@link DEV_ACTOR}. Only ever mounted when devAuthEnabled(). */
+/** A guard that admits everybody as {@link DEV_ACTOR}. Only ever mounted when singleUserEnabled(). */
export function createDevRequireUser(): MiddlewareHandler<{
Variables: AppVariables;
}> {
diff --git a/server/src/auth/index.ts b/server/src/auth/index.ts
index 8ef519f..5afc14d 100644
--- a/server/src/auth/index.ts
+++ b/server/src/auth/index.ts
@@ -1,22 +1,128 @@
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
+import { sso } from "@better-auth/sso";
import { betterAuth } from "better-auth";
+import { APIError } from "better-auth/api";
+import { genericOAuth, okta } from "better-auth/plugins";
+import { eq } from "drizzle-orm";
import type { DeploymentConfig } from "../config";
import type { Database } from "../db/client";
import {
accounts,
sessions,
- userRoles,
+ ssoProviders,
users,
verifications,
} from "../db/schema";
-import { roleForEmail } from "./roles";
+import { applyConfiguredAdmin, seedRole } from "./roles";
-export function createAuth(config: DeploymentConfig, database: Database) {
+/**
+ * An address for somebody arriving from Entra, whatever claim it turned up in.
+ *
+ * Entra does not always send `email`. Microsoft return it only when the profile carries an email
+ * attribute, and for a multi-tenant application optional claims may not arrive at all, because an
+ * external user's token is minted by their own tenant and does not inherit this application's claim
+ * configuration. `common`, the default tenant here, is multi-tenant.
+ *
+ * Better Auth maps `email` straight through with no fallback, so on those deployments it is
+ * undefined. That matters more here than in most products: every authorization decision OpenBot
+ * makes about a person is keyed on their address. `INITIAL_ADMIN_EMAILS`, the role, the deny list
+ * and the People screen all read it, so an absent address is not a cosmetic gap. Somebody would
+ * sign in successfully, match no administrator, and land as a plain user with nothing on any screen
+ * explaining why.
+ *
+ * `upn` first because it is the directory's own name for the account, then `preferred_username`,
+ * which the OIDC spec explicitly does not promise is an address but which Entra populates with the
+ * UPN in practice. Returning nothing when neither is present is deliberate: Better Auth then
+ * refuses the sign-in, and being refused is a far better answer than being quietly admitted as
+ * somebody the deployment cannot recognise.
+ */
+export function mapEntraProfile(profile: Record) {
+ const claim = (name: string) => {
+ const value = profile[name];
+ return typeof value === "string" && value.includes("@") ? value : undefined;
+ };
+
+ const email = claim("email") ?? claim("upn") ?? claim("preferred_username");
+ if (!email) {
+ console.error(
+ JSON.stringify({
+ type: "entra-profile-missing-email",
+ note: "Entra returned no email, upn or preferred_username claim, so this person cannot be identified. Add `email` as an optional claim on the app registration, or use a single-tenant MICROSOFT_OAUTH_TENANT_ID.",
+ claims: Object.keys(profile),
+ }),
+ );
+ return {};
+ }
+
+ return { email };
+}
+
+export function createAuth(
+ config: DeploymentConfig,
+ database: Database,
+ /**
+ * Whether an administrator has removed this address.
+ *
+ * Checked here rather than only in the request guard, because a removed person whose sign-in
+ * still succeeds gets a session, a user row and a place in the list: the removal would read as
+ * having worked while quietly not having.
+ */
+ isRevoked?: (email: string) => Promise,
+) {
const authConfig = config.auth;
if (!authConfig) {
- throw new Error("Google authentication is not configured.");
+ throw new Error("No identity provider is configured.");
}
+ /*
+ * Okta goes through the generic OAuth plugin, the other two do not.
+ *
+ * Google and Entra are named providers that Better Auth knows the endpoints of. Okta is not one
+ * place: every customer has their own issuer, so it is OIDC discovery against a URL rather than a
+ * provider with a fixed address. The plugin is only registered when Okta is configured, so a
+ * deployment that does not use it carries no extra routes.
+ *
+ * They converge again at the browser: `signIn.social({ provider })` starts all three, so the
+ * sign-in screen has one code path and does not need to know which kind each provider is.
+ */
+ const plugins = [
+ ...(authConfig.okta
+ ? [
+ genericOAuth({
+ config: [
+ okta({
+ clientId: authConfig.okta.clientId,
+ clientSecret: authConfig.okta.clientSecret,
+ issuer: authConfig.okta.issuer,
+ }),
+ ],
+ }),
+ ]
+ : []),
+ /*
+ * Identity providers a company registers while this is running, by SAML or OIDC.
+ *
+ * Always on, unlike the three above, because it has nothing to configure: what it can do
+ * depends entirely on what an administrator has registered, and an empty table means it offers
+ * nothing. Turning it on and off would only mean a deployment could hold a registered IdP that
+ * silently stopped working.
+ *
+ * `provisionUser` runs when somebody arrives through one of them. Their role has to be written
+ * here or they land with no role at all and the request guard refuses them with a 403, which
+ * reads as a broken deployment rather than a first sign-in.
+ */
+ sso({
+ provisionUser: async ({ user }) => {
+ await seedRole(
+ database,
+ user.id,
+ user.email,
+ authConfig.initialAdminEmails,
+ );
+ },
+ }),
+ ];
+
return betterAuth({
baseURL: authConfig.baseUrl,
secret: authConfig.secret,
@@ -24,22 +130,86 @@ export function createAuth(config: DeploymentConfig, database: Database) {
database: drizzleAdapter(database, {
provider: "pg",
usePlural: true,
- schema: { users, sessions, accounts, verifications },
+ schema: { users, sessions, accounts, verifications, ssoProviders },
}),
+ plugins,
socialProviders: {
- google: authConfig.google,
+ ...(authConfig.google ? { google: authConfig.google } : {}),
+ ...(authConfig.microsoft
+ ? {
+ microsoft: {
+ clientId: authConfig.microsoft.clientId,
+ clientSecret: authConfig.microsoft.clientSecret,
+ tenantId: authConfig.microsoft.tenantId,
+ mapProfileToUser: mapEntraProfile,
+ },
+ }
+ : {}),
},
databaseHooks: {
user: {
create: {
+ /*
+ * Refuse before the account exists.
+ *
+ * Somebody removed and then signing in again would otherwise arrive as a brand-new person
+ * with a fresh id, no role and no memory of having been removed, which is why the deny
+ * list is keyed on the address rather than the id.
+ */
+ before: async (user) => {
+ if (await isRevoked?.(user.email)) {
+ throw new APIError("FORBIDDEN", {
+ message: "Your access to this deployment has been removed.",
+ });
+ }
+ return { data: user };
+ },
after: async (user) => {
- await database
- .insert(userRoles)
- .values({
- userId: user.id,
- role: roleForEmail(user.email, authConfig.initialAdminEmails),
- })
- .onConflictDoNothing();
+ /*
+ * Who is an administrator is decided by email, not by which provider signed them in. A
+ * deployment mid-migration has the same person arriving through Entra one week and
+ * Okta the next, and they are the same person to this list.
+ */
+ await seedRole(
+ database,
+ user.id,
+ user.email,
+ authConfig.initialAdminEmails,
+ );
+ },
+ },
+ },
+ session: {
+ create: {
+ /*
+ * And again for somebody who already has an account. The user hook above only fires for a
+ * new one, so without this a removed person signs straight back in.
+ */
+ before: async (session) => {
+ const [user] = await database
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, session.userId))
+ .limit(1);
+ if (user && (await isRevoked?.(user.email))) {
+ throw new APIError("FORBIDDEN", {
+ message: "Your access to this deployment has been removed.",
+ });
+ }
+ return { data: session };
+ },
+ after: async (session) => {
+ /*
+ * The configured floor, re-applied on every sign-in. Editing the list has to mean
+ * something for people already in the table, or adding yourself after you first signed
+ * in silently does nothing. Only promotes, and only addresses the list names: everybody
+ * else's role belongs to the admin screen.
+ */
+ await applyConfiguredAdmin(
+ database,
+ session.userId,
+ authConfig.initialAdminEmails,
+ );
},
},
},
diff --git a/server/src/auth/roles.ts b/server/src/auth/roles.ts
index caae861..59201dc 100644
--- a/server/src/auth/roles.ts
+++ b/server/src/auth/roles.ts
@@ -1,14 +1,102 @@
+import { and, eq, ne } from "drizzle-orm";
+import type { Database } from "../db/client";
+import { userRoles, users } from "../db/schema";
+
export type OpenBotRole = "admin" | "user";
-export function roleForEmail(
+/**
+ * Whether this address is an administrator by configuration.
+ *
+ * A floor, not the whole answer. Somebody named here is always an administrator and cannot be
+ * demoted from the admin screen, which is what makes it the way back in when the last administrator
+ * demotes themselves by accident. Everybody else's role is whatever an administrator has set.
+ */
+export function isConfiguredAdmin(
email: string,
initialAdminEmails: readonly string[],
-): OpenBotRole {
+): boolean {
const normalizedEmail = email.trim().toLowerCase();
return initialAdminEmails.some(
(adminEmail) => adminEmail.trim().toLowerCase() === normalizedEmail,
- )
- ? "admin"
- : "user";
+ );
+}
+
+export function roleForEmail(
+ email: string,
+ initialAdminEmails: readonly string[],
+): OpenBotRole {
+ return isConfiguredAdmin(email, initialAdminEmails) ? "admin" : "user";
+}
+
+/**
+ * Give somebody exactly one role.
+ *
+ * `user_roles` is a set with a `(user_id, role)` primary key and the guard takes `admin` if any row
+ * says so, so setting a role means removing the rows that should not be there rather than only
+ * inserting one. Both statements inside one transaction: between them a request arriving on another
+ * process would find no role at all and be refused with a 403 that reads as a permissions bug.
+ */
+export async function setRole(
+ database: Database,
+ userId: string,
+ role: OpenBotRole,
+): Promise {
+ await database.transaction(async (tx) => {
+ await tx
+ .delete(userRoles)
+ .where(and(eq(userRoles.userId, userId), ne(userRoles.role, role)));
+ await tx.insert(userRoles).values({ userId, role }).onConflictDoNothing();
+ });
+
+ return role;
+}
+
+/**
+ * The role a brand-new account starts with.
+ *
+ * The only moment configuration decides somebody who is not on the list: from here on that person's
+ * role belongs to whoever administers the deployment.
+ */
+export async function seedRole(
+ database: Database,
+ userId: string,
+ email: string,
+ initialAdminEmails: readonly string[],
+): Promise {
+ return setRole(database, userId, roleForEmail(email, initialAdminEmails));
+}
+
+/**
+ * Re-apply the configured floor, on every sign-in.
+ *
+ * Only ever promotes, and only for an address the deployment names. Somebody added to the list
+ * after they first signed in becomes an administrator at their next sign-in, which is the trap this
+ * exists to close: the role used to be written once at account creation, so editing the list did
+ * nothing at all and no screen could fix it.
+ *
+ * Everybody else is left exactly as they are, because their role is the admin screen's to decide and
+ * a sign-in that overwrote it would make that screen lie the moment they came back.
+ */
+export async function applyConfiguredAdmin(
+ database: Database,
+ userId: string,
+ initialAdminEmails: readonly string[],
+): Promise {
+ // Nothing configured cannot promote anybody, so there is no reason to read the user back.
+ if (initialAdminEmails.length === 0) return;
+
+ const [user] = await database
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ // No user means a session is being made for somebody who is not there, which is not this module's
+ // to report: Better Auth is about to fail on its own and would only be given a worse message here.
+ if (!user) return;
+
+ if (!isConfiguredAdmin(user.email, initialAdminEmails)) return;
+
+ await setRole(database, userId, "admin");
}
diff --git a/server/src/config.ts b/server/src/config.ts
index 6d67d2e..72ea972 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -3,7 +3,7 @@
* for durable threads and memory. Configuration the product cannot function without belongs at the
* boot boundary.
*/
-import { devAuthEnabled } from "./auth/dev-actor";
+import { singleUserEnabled } from "./auth/dev-actor";
import type { ActionPolicy } from "./computer/policy";
import { parseActionPolicy } from "./computer/policy-store";
@@ -40,6 +40,52 @@ export type SharedComputerConfig = {
export type ComputerConfig = DockerComputerConfig | SharedComputerConfig;
+/**
+ * Who a deployment lets in, and through which front door.
+ *
+ * One identity provider is a product decision somebody else already made. A company running this
+ * has Google or Entra or Okta and is not going to acquire another, so the shape here is a set of
+ * optional providers rather than one required one, and the deployment turns on whichever it has.
+ */
+export type AuthProviderId = "google" | "microsoft" | "okta";
+
+/** An OAuth client, as every provider here needs one. */
+export type OAuthClient = { clientId: string; clientSecret: string };
+
+export type AuthConfig = {
+ baseUrl: string;
+ secret: string;
+ trustedOrigins: string[];
+ initialAdminEmails: string[];
+ google?: OAuthClient;
+ /**
+ * `tenantId` decides who may sign in at all, so it is not a detail. `common` admits any Microsoft
+ * account including personal ones, `organizations` any work or school account anywhere, and a GUID
+ * admits one directory. A deployment that wants only its own company needs the GUID.
+ */
+ microsoft?: OAuthClient & { tenantId: string };
+ /** Okta is an OIDC provider rather than a named one, so it is identified by its issuer. */
+ okta?: OAuthClient & { issuer: string };
+};
+
+/**
+ * The providers this deployment can actually sign somebody in with.
+ *
+ * Ordered, and deliberately not alphabetically: this is the order the buttons appear in, and it is
+ * fixed here rather than left to object key order so the sign-in screen cannot change shape because
+ * of how a configuration happened to be written.
+ */
+export function configuredAuthProviders(
+ auth: AuthConfig | undefined,
+): AuthProviderId[] {
+ if (!auth) return [];
+ const providers: AuthProviderId[] = [];
+ if (auth.google) providers.push("google");
+ if (auth.microsoft) providers.push("microsoft");
+ if (auth.okta) providers.push("okta");
+ return providers;
+}
+
export type DeploymentConfig = {
databaseUrl: string;
keyEncryptionKey: string;
@@ -67,18 +113,14 @@ export type DeploymentConfig = {
oauth: {
google?: { clientId: string; clientSecret: string };
};
- auth?: {
- baseUrl: string;
- secret: string;
- google: { clientId: string; clientSecret: string };
- trustedOrigins: string[];
- initialAdminEmails: string[];
- };
+ auth?: AuthConfig;
/**
- * Local development only: admit everybody as a fixed administrator instead of requiring sign-in.
- * See auth/dev-actor.ts for the two locks that stop this reaching a deployment.
+ * Admit everybody as one fixed administrator instead of requiring sign-in.
+ *
+ * True only when no identity provider is configured. See auth/dev-actor.ts for what stops this
+ * reaching somewhere other people can get to.
*/
- devNoAuth: boolean;
+ singleUser: boolean;
/** Names OpenBot on the analytics the runtime already sends. Off with OPENBOT_ACCESSIBILITY_DISABLED. */
accessibility: boolean;
/**
@@ -189,8 +231,8 @@ function requiredHttpUrl(environment: Environment, name: string): URL {
function oauthClient(
environment: Environment,
- provider: "GOOGLE",
-): { clientId: string; clientSecret: string } | undefined {
+ provider: "GOOGLE" | "MICROSOFT" | "OKTA",
+): OAuthClient | undefined {
const clientId = optional(environment, `${provider}_OAUTH_CLIENT_ID`);
const clientSecret = optional(environment, `${provider}_OAUTH_CLIENT_SECRET`);
@@ -198,7 +240,7 @@ function oauthClient(
// than at start-up, which is the worst moment to discover it.
if (Boolean(clientId) !== Boolean(clientSecret)) {
throw new Error(
- "Google OAuth configuration requires both client ID and client secret",
+ `${provider}_OAUTH_CLIENT_ID and ${provider}_OAUTH_CLIENT_SECRET must be set together`,
);
}
@@ -212,41 +254,121 @@ function commaSeparated(environment: Environment, name: string): string[] {
.filter(Boolean);
}
+/**
+ * Sign-in, if this deployment has an identity provider to sign people in with.
+ *
+ * Any one of the three turns authentication on. More than one is allowed and is the normal shape
+ * for a company mid-migration, where some people are on Entra and some are still on Okta.
+ *
+ * Every combination that cannot work refuses at start-up rather than at somebody's first attempt to
+ * sign in, which is the worst moment to discover it: a provider with half its credentials, a
+ * provider with no session secret to mint against, or a session secret configured with no provider
+ * to use it.
+ */
function authConfig(
environment: Environment,
- google: { clientId: string; clientSecret: string } | undefined,
-): DeploymentConfig["auth"] {
+ google: OAuthClient | undefined,
+): AuthConfig | undefined {
+ const microsoft = microsoftAuth(environment);
+ const okta = oktaAuth(environment);
+
const secret = optional(environment, "BETTER_AUTH_SECRET");
const baseUrl = url(environment, "BETTER_AUTH_URL");
- if (!google) {
+
+ if (!google && !microsoft && !okta) {
if (secret || baseUrl) {
throw new Error(
- "Google authentication requires GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET",
+ "BETTER_AUTH_SECRET or BETTER_AUTH_URL is set but no identity provider is. Configure GOOGLE_OAUTH_*, MICROSOFT_OAUTH_* or OKTA_OAUTH_*, or unset both",
);
}
return undefined;
}
if (!secret) {
- throw new Error("Google authentication requires BETTER_AUTH_SECRET");
+ throw new Error("Sign-in requires BETTER_AUTH_SECRET");
}
if (secret.length < 32) {
throw new Error("BETTER_AUTH_SECRET must be at least 32 characters");
}
if (!baseUrl) {
- throw new Error("Google authentication requires BETTER_AUTH_URL");
+ throw new Error("Sign-in requires BETTER_AUTH_URL");
+ }
+
+ /*
+ * Somebody has to be an administrator, and only this says who.
+ *
+ * The role is written from this list and there is no route anywhere that changes one, so a
+ * deployment that configures sign-in without it admits everybody as a plain user, shows nobody
+ * the admin screens, and offers no way to promote anyone. Refusing at start-up is the only cheap
+ * moment to catch that; the expensive one is after the first person has signed in.
+ */
+ const initialAdminEmails = commaSeparated(
+ environment,
+ "INITIAL_ADMIN_EMAILS",
+ );
+ if (initialAdminEmails.length === 0) {
+ throw new Error(
+ "Sign-in requires INITIAL_ADMIN_EMAILS naming at least one administrator. Nothing else grants the role, and no screen can promote somebody once the deployment is running",
+ );
}
return {
baseUrl,
secret,
- google,
trustedOrigins: commaSeparated(environment, "TRUSTED_ORIGINS").length
? commaSeparated(environment, "TRUSTED_ORIGINS")
: ["http://localhost:3000"],
- initialAdminEmails: commaSeparated(environment, "INITIAL_ADMIN_EMAILS"),
+ initialAdminEmails,
+ ...(google ? { google } : {}),
+ ...(microsoft ? { microsoft } : {}),
+ ...(okta ? { okta } : {}),
+ };
+}
+
+/**
+ * Entra ID, and which directory it admits.
+ *
+ * `common` by default, matching Microsoft's own default, and said out loud in `.env.example` because
+ * it admits personal Microsoft accounts as well as work ones. A company that means "our staff"
+ * wants its directory GUID here.
+ */
+function microsoftAuth(
+ environment: Environment,
+): (OAuthClient & { tenantId: string }) | undefined {
+ const client = oauthClient(environment, "MICROSOFT");
+ if (!client) return undefined;
+ return {
+ ...client,
+ tenantId: optional(environment, "MICROSOFT_OAUTH_TENANT_ID") ?? "common",
};
}
+/**
+ * Okta, which is an OIDC provider rather than a named one.
+ *
+ * The issuer is what makes it a particular Okta rather than Okta in general, so it is required
+ * alongside the credentials rather than defaulted to anything.
+ */
+function oktaAuth(
+ environment: Environment,
+): (OAuthClient & { issuer: string }) | undefined {
+ const client = oauthClient(environment, "OKTA");
+ const issuer = url(environment, "OKTA_OAUTH_ISSUER");
+ if (!client) {
+ if (issuer) {
+ throw new Error(
+ "OKTA_OAUTH_ISSUER is set but OKTA_OAUTH_CLIENT_ID and OKTA_OAUTH_CLIENT_SECRET are not",
+ );
+ }
+ return undefined;
+ }
+ if (!issuer) {
+ throw new Error(
+ "Okta sign-in requires OKTA_OAUTH_ISSUER, such as https://example.okta.com/oauth2/default",
+ );
+ }
+ return { ...client, issuer };
+}
+
/**
* Resolve the Intelligence contract, or refuse to start.
*
@@ -391,6 +513,7 @@ export function loadConfig(
environment: Environment = process.env,
): DeploymentConfig {
const google = oauthClient(environment, "GOOGLE");
+ const auth = authConfig(environment, google);
return {
databaseUrl: required(environment, "DATABASE_URL"),
@@ -406,8 +529,11 @@ export function loadConfig(
runtime: runtimeCapabilities(environment),
agentStallTimeoutMs: agentStallTimeoutMs(environment),
oauth: { google },
- auth: authConfig(environment, google),
- devNoAuth: devAuthEnabled(environment),
+ auth,
+ singleUser: singleUserEnabled(
+ environment,
+ configuredAuthProviders(auth).length > 0,
+ ),
accessibility: accessibilityEnabled(environment),
...(optional(environment, "APP_DIST_DIR")
? { appDistDir: optional(environment, "APP_DIST_DIR") as string }
diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts
index 445f6c4..abf2640 100644
--- a/server/src/db/schema/core.ts
+++ b/server/src/db/schema/core.ts
@@ -74,6 +74,16 @@ export const accounts = pgTable(
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
+ /*
+ * Who vouched for this account, as the identity provider names itself.
+ *
+ * Required by Better Auth from 1.7. A real OIDC provider supplies its own
+ * (`https://accounts.google.com`), and one without gets a synthetic
+ * `local:oauth:`, so the column is never empty. It exists because `providerId`
+ * alone stopped being enough once a deployment can register more than one OIDC provider: two
+ * companies' Okta tenants are both "okta" and are not the same directory.
+ */
+ issuer: text("issuer").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -120,6 +130,50 @@ export const userRoles = pgTable(
(table) => [primaryKey({ columns: [table.userId, table.role] })],
);
+/**
+ * An enterprise identity provider this deployment has been told about.
+ *
+ * The other three providers are configuration: one Google, one Entra, one Okta, named in the
+ * environment. These are not, because a company's own IdP is not something a deployment can be
+ * built knowing. It is registered while running, by an administrator holding the metadata their
+ * identity team gave them, and there can be several.
+ *
+ * `oidcConfig` and `samlConfig` are JSON held as text because Better Auth writes them that way. They
+ * carry a client secret or a signing certificate, so nothing here is ever projected to a browser.
+ *
+ * `domain` is what routes somebody to the right one: they type an email address, and the part after
+ * the @ decides which identity provider is asked about them.
+ */
+export const ssoProviders = pgTable("sso_providers", {
+ id: text("id").primaryKey(),
+ issuer: text("issuer").notNull(),
+ oidcConfig: text("oidc_config"),
+ samlConfig: text("saml_config"),
+ userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
+ providerId: text("provider_id").notNull().unique(),
+ organizationId: text("organization_id"),
+ domain: text("domain").notNull(),
+});
+
+/**
+ * People an administrator has removed, by email address.
+ *
+ * Keyed on the address rather than the user id, because deleting the user row is not removal: the
+ * next sign-in through the identity provider creates it again, with a fresh id and no memory of
+ * having been removed. The address is the only thing that survives that.
+ *
+ * Lower-cased on the way in, since a provider is free to return whatever case it likes and two rows
+ * differing only in case would be one person with one of them enforced.
+ */
+export const revokedAccess = pgTable("revoked_access", {
+ email: text("email").primaryKey(),
+ revokedAt: timestamp("revoked_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ /** Who did it, for the trail. Not a foreign key: an administrator may later be removed too. */
+ revokedBy: text("revoked_by").notNull(),
+});
+
export const deploymentPackages = pgTable("deployment_packages", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: text("tenant_id").notNull().unique(),
diff --git a/server/src/index.ts b/server/src/index.ts
index 54aaf3c..38e062f 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1,6 +1,7 @@
import { serve } from "bun";
-import { createAgentProfileStore } from "./agents/profile-store";
+import { sql } from "drizzle-orm";
import { mintRunAssertion } from "./agents/callback-token";
+import { createAgentProfileStore } from "./agents/profile-store";
import { createRuntimeAgentLoader } from "./agents/runtime-agents";
import { createApp } from "./app";
import { createAuditReader, createAuditStore, recordAuditEvent } from "./audit";
@@ -13,9 +14,9 @@ import {
startChannelActivityListener,
} from "./channels/events";
import { createChannelStore } from "./channels/routes";
+import { websocket as channelSocket } from "./channels/socket";
import { createStallGuard } from "./channels/stall-guard";
import { createThreadIdentity } from "./channels/thread-identity";
-import { websocket as channelSocket } from "./channels/socket";
import { createSandboxedStore } from "./components/sandboxed";
import { createComponentStore } from "./components/store";
import { createComputerGateway } from "./computer/gateway";
@@ -40,6 +41,8 @@ import {
resolveModelApiKey,
} from "./credentials";
import { createDatabase } from "./db/client";
+import { ssoProviders } from "./db/schema";
+import { createPeopleStore } from "./people/store";
import { createPluginStore } from "./plugins/store";
import { grantedTools } from "./plugins/tools";
import {
@@ -60,7 +63,7 @@ async function resolveRequestActor(request: Request): Promise<{
name: string;
role: OpenBotRole;
}> {
- if (config.devNoAuth) {
+ if (config.singleUser) {
return { id: DEV_ACTOR.id, name: DEV_ACTOR.email, role: DEV_ACTOR.role };
}
const session = await auth?.api.getSession({ headers: request.headers });
@@ -109,7 +112,7 @@ const identifyActor: IdentifyActor = async (request) => {
const config = loadConfig();
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
const database = createDatabase(config.databaseUrl);
-await initializeDevActorUser(database, config.devNoAuth);
+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);
@@ -156,7 +159,18 @@ const loadAgentsForActor = createRuntimeAgentLoader(database, agentVault, {
token: config.managedAgentToken,
});
await synchronizeTenantPackage(database, tenantPackage);
-const auth = config.auth ? createAuth(config, database) : undefined;
+/*
+ * Built before `auth`, because the deny list is consulted during sign-in and the store is what
+ * holds it. It needs the administrator list too, so it can tell the screen which people the
+ * deployment's configuration has already decided about.
+ */
+const peopleStore = createPeopleStore(
+ database,
+ config.auth?.initialAdminEmails ?? [],
+);
+const auth = config.auth
+ ? createAuth(config, database, (email) => peopleStore.isRevoked(email))
+ : undefined;
const computerProvider = config.computer
? createComputerProvider(config.computer)
: undefined;
@@ -367,6 +381,15 @@ const app = createApp(
sandboxedStore,
// How a thread that has no channel is named, so the direct Bot chat is in the same namespace.
threadIdentity,
+ // Who has signed in, and what an administrator may do about them.
+ peopleStore,
+ // Whether the sign-in screen should offer the email box that routes by domain.
+ async () => {
+ const [row] = await database
+ .select({ count: sql`count(*)::int` })
+ .from(ssoProviders);
+ return row?.count ?? 0;
+ },
);
/**
@@ -506,11 +529,12 @@ serve({
},
});
-if (config.devNoAuth) {
+if (config.singleUser) {
// Loud, every boot. A server that is not checking who is asking should never be a quiet default.
console.warn(
- "OPENBOT_DEV_NO_AUTH is on: every request is treated as " +
- `${DEV_ACTOR.email} (administrator). Local development only.`,
+ "No identity provider is configured, so every request is treated as " +
+ `${DEV_ACTOR.email} (administrator). Configure GOOGLE_OAUTH_*, ` +
+ "MICROSOFT_OAUTH_* or OKTA_OAUTH_* before anybody else can reach this.",
);
}
diff --git a/server/src/people/store.ts b/server/src/people/store.ts
new file mode 100644
index 0000000..110a221
--- /dev/null
+++ b/server/src/people/store.ts
@@ -0,0 +1,172 @@
+import { eq, inArray, sql } from "drizzle-orm";
+import { isConfiguredAdmin, type OpenBotRole, setRole } from "../auth/roles";
+import type { Database } from "../db/client";
+import {
+ accounts,
+ revokedAccess,
+ sessions,
+ userRoles,
+ users,
+} from "../db/schema";
+
+/**
+ * Everybody who has signed in, and what an administrator may do about them.
+ *
+ * People appear here by having signed in, not by being invited: a deployment's identity provider
+ * decides who exists, and this decides what they may do once they are here.
+ */
+export type Person = {
+ id: string;
+ email: string;
+ name: string | null;
+ image: string | null;
+ role: OpenBotRole;
+ /**
+ * Which identity providers this person has arrived through. More than one is normal for a company
+ * mid-migration, where the same address exists in both Entra and Okta.
+ */
+ providers: string[];
+ lastSignedInAt: string | null;
+ /** Whether an administrator has removed them. A revoked person keeps their row and their history. */
+ revoked: boolean;
+ /**
+ * Whether this person's role is fixed by `INITIAL_ADMIN_EMAILS`.
+ *
+ * The screen renders this rather than recomputing it: the deployment's configuration is the floor
+ * that guarantees a way back in, so somebody it names cannot be demoted or removed here.
+ */
+ configuredAdmin: boolean;
+};
+
+export type PeopleStore = {
+ list: () => Promise;
+ setRole: (userId: string, role: OpenBotRole) => Promise;
+ revoke: (userId: string, revokedBy: string) => Promise;
+ restore: (userId: string) => Promise;
+ find: (userId: string) => Promise;
+ isRevoked: (email: string) => Promise;
+};
+
+/** One spelling of an address, so a provider's choice of case cannot create a second person. */
+function normalize(email: string): string {
+ return email.trim().toLowerCase();
+}
+
+export function createPeopleStore(
+ database: Database,
+ initialAdminEmails: readonly string[],
+): PeopleStore {
+ async function list(): Promise {
+ const rows = await database
+ .select({
+ id: users.id,
+ email: users.email,
+ name: users.name,
+ image: users.image,
+ /*
+ * Aggregated rather than joined into duplicate rows. `user_roles` is a set and `accounts`
+ * has one row per provider, so a plain join would return the same person once per
+ * combination and the screen would list them several times.
+ */
+ roles: sql<
+ string[]
+ >`coalesce(array_agg(distinct ${userRoles.role}) filter (where ${userRoles.role} is not null), '{}')`,
+ providers: sql<
+ string[]
+ >`coalesce(array_agg(distinct ${accounts.providerId}) filter (where ${accounts.providerId} is not null), '{}')`,
+ lastSignedInAt: sql`max(${sessions.createdAt})`,
+ revoked: sql`bool_or(${revokedAccess.email} is not null)`,
+ })
+ .from(users)
+ .leftJoin(userRoles, eq(userRoles.userId, users.id))
+ .leftJoin(accounts, eq(accounts.userId, users.id))
+ .leftJoin(sessions, eq(sessions.userId, users.id))
+ .leftJoin(
+ revokedAccess,
+ eq(revokedAccess.email, sql`lower(${users.email})`),
+ )
+ .groupBy(users.id)
+ /*
+ * Most recently here first, and `NULLS LAST` on purpose.
+ *
+ * Postgres sorts nulls first on a descending order, so without it everybody who has never
+ * signed in floats above everybody who just did. On a deployment of any size that is the
+ * whole first screen given to people who have never used it.
+ */
+ .orderBy(sql`max(${sessions.createdAt}) desc nulls last`, users.email);
+
+ return rows.map((row) => ({
+ id: row.id,
+ email: row.email,
+ name: row.name,
+ image: row.image,
+ // `admin` wins, the same way the request guard reads it. Anything else is a plain user.
+ role: row.roles.includes("admin") ? "admin" : "user",
+ providers: row.providers,
+ lastSignedInAt: row.lastSignedInAt
+ ? new Date(row.lastSignedInAt).toISOString()
+ : null,
+ revoked: row.revoked === true,
+ configuredAdmin: isConfiguredAdmin(row.email, initialAdminEmails),
+ }));
+ }
+
+ async function find(userId: string): Promise {
+ return (await list()).find((person) => person.id === userId);
+ }
+
+ return {
+ list,
+ find,
+
+ async setRole(userId, role) {
+ await setRole(database, userId, role);
+ },
+
+ /**
+ * Remove somebody, and end the session they are using.
+ *
+ * Both halves matter. The deny list stops the next sign-in, and deleting the sessions stops the
+ * current one: without that, somebody removed keeps working until their cookie happens to
+ * expire, which can be days.
+ */
+ async revoke(userId, revokedBy) {
+ const [user] = await database
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+ if (!user) return;
+
+ await database.transaction(async (tx) => {
+ await tx
+ .insert(revokedAccess)
+ .values({ email: normalize(user.email), revokedBy })
+ .onConflictDoNothing();
+ await tx.delete(sessions).where(eq(sessions.userId, userId));
+ });
+ },
+
+ async restore(userId) {
+ const [user] = await database
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+ if (!user) return;
+
+ await database
+ .delete(revokedAccess)
+ .where(eq(revokedAccess.email, normalize(user.email)));
+ },
+
+ async isRevoked(email) {
+ const rows = await database
+ .select({ email: revokedAccess.email })
+ .from(revokedAccess)
+ .where(inArray(revokedAccess.email, [normalize(email)]))
+ .limit(1);
+ return rows.length > 0;
+ },
+ };
+}
diff --git a/server/src/tenant-package.ts b/server/src/tenant-package.ts
index 2de9ee8..7f56e24 100644
--- a/server/src/tenant-package.ts
+++ b/server/src/tenant-package.ts
@@ -141,19 +141,24 @@ export type ApplicationConfiguration = {
tenantId: string;
productName: string;
};
- auth: { providers: string[] };
};
+/**
+ * What the browser is told about this deployment at build time.
+ *
+ * Brand only. Which identity providers are configured used to live here too, and could not: the
+ * image is built once, without any deployment's environment, so a deployment that configured Entra
+ * got a sign-in screen built on a machine that had never heard of it. That answer now comes from
+ * `/api/capabilities` at runtime, where the process that knows can answer.
+ */
export function createApplicationConfiguration(
tenantPackage: TenantPackage,
- providers: string[],
): ApplicationConfiguration {
return {
brand: {
tenantId: tenantPackage.tenantId,
productName: tenantPackage.productName,
},
- auth: { providers },
};
}
diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts
index eb0de13..60e580b 100644
--- a/server/tests/config.test.ts
+++ b/server/tests/config.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { loadConfig } from "../src/config";
+import { configuredAuthProviders, loadConfig } from "../src/config";
// Intelligence is part of the MINIMUM contract, so it belongs in the base environment every other
// case builds on. Leaving it out of the base would make most of this file assert the behaviour of a
@@ -11,6 +11,7 @@ const baseEnvironment = {
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",
@@ -19,6 +20,22 @@ const baseEnvironment = {
MANAGED_AGENT_TOKEN: "managed-agent-token",
};
+/**
+ * The same deployment with nothing signing anybody in.
+ *
+ * `baseEnvironment` ships Google and a session secret because most tests want authentication on.
+ * The provider tests need the opposite starting point, or "Microsoft is configured" cannot be told
+ * apart from "Microsoft and the Google that was already there".
+ */
+const {
+ GOOGLE_OAUTH_CLIENT_ID: _googleId,
+ GOOGLE_OAUTH_CLIENT_SECRET: _googleSecret,
+ BETTER_AUTH_SECRET: _authSecret,
+ BETTER_AUTH_URL: _authUrl,
+ INITIAL_ADMIN_EMAILS: _adminEmails,
+ ...withoutSignIn
+} = baseEnvironment;
+
describe("deployment configuration", () => {
test("resolves the Intelligence runtime, which is the only runtime", () => {
const config = loadConfig(baseEnvironment);
@@ -92,7 +109,7 @@ describe("deployment configuration", () => {
GOOGLE_OAUTH_CLIENT_SECRET: "",
}),
).toThrow(
- "Google OAuth configuration requires both client ID and client secret",
+ "GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET must be set together",
);
});
@@ -154,6 +171,142 @@ describe("deployment configuration", () => {
});
});
+ /**
+ * Sign-in with more than one identity provider.
+ *
+ * A company mid-migration has some people on Entra and some still on Okta, so more than one at a
+ * time is the normal shape rather than a corner. These assert the shape the sign-in screen reads
+ * and every arrangement that cannot work refusing at start-up, which is the only moment a
+ * misconfiguration is cheap to find.
+ */
+ const SESSION = {
+ BETTER_AUTH_SECRET: "a-long-enough-local-development-auth-secret",
+ BETTER_AUTH_URL: "http://localhost:3001",
+ INITIAL_ADMIN_EMAILS: "admin@openbot.test",
+ };
+
+ test("enables Microsoft, and admits any account until told a directory", () => {
+ const config = loadConfig({
+ ...withoutSignIn,
+ ...SESSION,
+ MICROSOFT_OAUTH_CLIENT_ID: "entra-client-id",
+ MICROSOFT_OAUTH_CLIENT_SECRET: "entra-client-secret",
+ });
+
+ // `common` is Microsoft's own default and admits personal accounts as well as work ones. A
+ // deployment that means "our staff" has to say so with a directory GUID.
+ expect(config.auth?.microsoft).toEqual({
+ clientId: "entra-client-id",
+ clientSecret: "entra-client-secret",
+ tenantId: "common",
+ });
+ expect(configuredAuthProviders(config.auth)).toEqual(["microsoft"]);
+ });
+
+ test("narrows Microsoft to one directory when given a tenant", () => {
+ const config = loadConfig({
+ ...withoutSignIn,
+ ...SESSION,
+ MICROSOFT_OAUTH_CLIENT_ID: "entra-client-id",
+ MICROSOFT_OAUTH_CLIENT_SECRET: "entra-client-secret",
+ MICROSOFT_OAUTH_TENANT_ID: "8f2c1e40-0000-0000-0000-000000000000",
+ });
+
+ expect(config.auth?.microsoft?.tenantId).toBe(
+ "8f2c1e40-0000-0000-0000-000000000000",
+ );
+ });
+
+ test("enables Okta against its issuer", () => {
+ const config = loadConfig({
+ ...withoutSignIn,
+ ...SESSION,
+ OKTA_OAUTH_CLIENT_ID: "okta-client-id",
+ OKTA_OAUTH_CLIENT_SECRET: "okta-client-secret",
+ OKTA_OAUTH_ISSUER: "https://example.okta.com/oauth2/default",
+ });
+
+ expect(config.auth?.okta).toEqual({
+ clientId: "okta-client-id",
+ clientSecret: "okta-client-secret",
+ issuer: "https://example.okta.com/oauth2/default",
+ });
+ });
+
+ test("refuses Okta without an issuer, which names no particular Okta", () => {
+ expect(() =>
+ loadConfig({
+ ...withoutSignIn,
+ ...SESSION,
+ OKTA_OAUTH_CLIENT_ID: "okta-client-id",
+ OKTA_OAUTH_CLIENT_SECRET: "okta-client-secret",
+ }),
+ ).toThrow("OKTA_OAUTH_ISSUER");
+ });
+
+ test("refuses an Okta issuer with no credentials behind it", () => {
+ expect(() =>
+ loadConfig({
+ ...withoutSignIn,
+ ...SESSION,
+ OKTA_OAUTH_ISSUER: "https://example.okta.com/oauth2/default",
+ }),
+ ).toThrow("OKTA_OAUTH_CLIENT_ID");
+ });
+
+ test("carries all three at once, in a fixed order", () => {
+ const config = loadConfig({
+ ...withoutSignIn,
+ ...SESSION,
+ GOOGLE_OAUTH_CLIENT_ID: "google-client-id",
+ GOOGLE_OAUTH_CLIENT_SECRET: "google-client-secret",
+ MICROSOFT_OAUTH_CLIENT_ID: "entra-client-id",
+ MICROSOFT_OAUTH_CLIENT_SECRET: "entra-client-secret",
+ OKTA_OAUTH_CLIENT_ID: "okta-client-id",
+ OKTA_OAUTH_CLIENT_SECRET: "okta-client-secret",
+ OKTA_OAUTH_ISSUER: "https://example.okta.com/oauth2/default",
+ });
+
+ // The order the buttons appear in, fixed here so it cannot change with how a .env was written.
+ expect(configuredAuthProviders(config.auth)).toEqual([
+ "google",
+ "microsoft",
+ "okta",
+ ]);
+ });
+
+ /**
+ * Somebody has to be an administrator.
+ *
+ * The role is written from this list and no route anywhere changes one, so a deployment that
+ * configures sign-in without it admits everybody as a plain user and can never promote anyone.
+ * Start-up is the only cheap moment to notice.
+ */
+ test("refuses sign-in with nobody named as an administrator", () => {
+ const { INITIAL_ADMIN_EMAILS: _none, ...withoutAdmins } = baseEnvironment;
+
+ expect(() => loadConfig(withoutAdmins)).toThrow("INITIAL_ADMIN_EMAILS");
+ });
+
+ test("asks for no administrator when nothing signs anybody in", () => {
+ // One administrator either way, and no list to write. Requiring one here would mean a fresh
+ // clone could not start.
+ expect(() => loadConfig(withoutSignIn)).not.toThrow();
+ });
+
+ test("is off, and lists nothing, when no provider is configured", () => {
+ const config = loadConfig(withoutSignIn);
+
+ expect(config.auth).toBeUndefined();
+ expect(configuredAuthProviders(config.auth)).toEqual([]);
+ });
+
+ test("refuses a session secret with no provider to use it", () => {
+ expect(() => loadConfig({ ...withoutSignIn, ...SESSION })).toThrow(
+ "no identity provider",
+ );
+ });
+
test("rejects incomplete Google authentication deployment settings", () => {
expect(() =>
loadConfig({
@@ -163,7 +316,7 @@ describe("deployment configuration", () => {
BETTER_AUTH_SECRET: "",
BETTER_AUTH_URL: "http://localhost:3001",
}),
- ).toThrow("Google authentication requires BETTER_AUTH_SECRET");
+ ).toThrow("Sign-in requires BETTER_AUTH_SECRET");
});
// A turn that is ended is a turn somebody loses, so an unset variable leaves every stream alone
diff --git a/server/tests/entra-profile.test.ts b/server/tests/entra-profile.test.ts
new file mode 100644
index 0000000..803db05
--- /dev/null
+++ b/server/tests/entra-profile.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "bun:test";
+import { mapEntraProfile } from "../src/auth";
+
+/**
+ * Finding an address for somebody arriving from Entra.
+ *
+ * Entra does not always send `email`: Microsoft return it only when the profile carries an email
+ * attribute, and a multi-tenant application may receive no optional claims at all, because an
+ * external user's token is minted by their own tenant. `common`, the default tenant here, is
+ * multi-tenant.
+ *
+ * It matters more here than in most products because every authorization decision OpenBot makes
+ * about a person is keyed on their address, so an absent one is not cosmetic: they would sign in,
+ * match no administrator, and land as a plain user with nothing explaining why.
+ */
+describe("mapEntraProfile", () => {
+ test("uses the email claim when it is there", () => {
+ expect(
+ mapEntraProfile({ email: "someone@acme.com", upn: "other@acme.com" }),
+ ).toEqual({ email: "someone@acme.com" });
+ });
+
+ test("falls back to the directory's own name for the account", () => {
+ expect(mapEntraProfile({ upn: "someone@acme.com" })).toEqual({
+ email: "someone@acme.com",
+ });
+ });
+
+ test("falls back again to preferred_username", () => {
+ expect(mapEntraProfile({ preferred_username: "someone@acme.com" })).toEqual(
+ { email: "someone@acme.com" },
+ );
+ });
+
+ /*
+ * `preferred_username` is explicitly not promised to be an address by the OIDC spec: it may be a
+ * phone number or a bare username. Something without an @ is not an address and must not be
+ * written into a column every authorization rule reads.
+ */
+ test("ignores a preferred_username that is not an address", () => {
+ expect(mapEntraProfile({ preferred_username: "someone" })).toEqual({});
+ });
+
+ // Nothing, so Better Auth refuses the sign-in. Being refused is a better answer than being
+ // quietly admitted as somebody the deployment cannot recognise.
+ test("finds nothing rather than inventing something", () => {
+ expect(mapEntraProfile({ oid: "abc", name: "A Person" })).toEqual({});
+ });
+});
diff --git a/server/tests/health.test.ts b/server/tests/health.test.ts
index e470210..24f112c 100644
--- a/server/tests/health.test.ts
+++ b/server/tests/health.test.ts
@@ -26,6 +26,11 @@ describe("runtime capabilities", () => {
await expect(response.json()).resolves.toEqual({
mode: "intelligence",
durableHistory: true,
+ // Names only. The sign-in screen reads this to know which buttons to draw.
+ authProviders: ["google"],
+ // A boolean, not a list: naming the registered providers would tell anybody who loads the
+ // sign-in page which companies use this deployment.
+ ssoConfigured: false,
});
});
@@ -39,12 +44,19 @@ describe("runtime capabilities", () => {
expect(body).not.toContain("tenant-api-key");
expect(body).not.toContain("license-token");
// The settings object itself must not be projected, whatever it happens to hold today.
- expect(Object.keys(parsed)).toEqual(["mode", "durableHistory"]);
+ expect(Object.keys(parsed)).toEqual([
+ "mode",
+ "durableHistory",
+ "authProviders",
+ "ssoConfigured",
+ ]);
+ // The provider list is names, never the clients and secrets behind them.
+ expect(body).not.toContain("google-client-secret");
});
});
describe("authentication availability", () => {
- test("fails loudly when Google authentication has not been configured", async () => {
+ test("fails loudly when no identity provider has been configured", async () => {
const response = await app.request(
"http://openbot.local/api/auth/sign-in/social",
{ method: "POST" },
@@ -52,7 +64,7 @@ describe("authentication availability", () => {
expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({
- error: "Google authentication is not configured.",
+ error: "No identity provider is configured.",
});
});
@@ -94,3 +106,80 @@ describe("authentication availability", () => {
expect(response.status).toBe(204);
});
});
+
+/**
+ * Who may register an identity provider.
+ *
+ * Better Auth's SSO plugin guards these with a session, which asks only that somebody is signed in.
+ * That is the wrong bar: registering an IdP for a domain means anybody it vouches for can sign in,
+ * so a plain user reaching it could mint themselves colleagues. These pin the gate in front of it.
+ */
+describe("identity provider registration", () => {
+ function appFor(roles: ("admin" | "user")[], signedIn = true) {
+ let reachedHandler = false;
+ const app = createApp(
+ loadConfig(testEnvironment()),
+ {
+ handler: () => {
+ reachedHandler = true;
+ return new Response(null, { status: 200 });
+ },
+ api: {
+ getSession: async () =>
+ signedIn ? { user: { id: "u1", email: "u1@openbot.test" } } : null,
+ },
+ } as never,
+ { rolesForUser: async () => roles },
+ );
+ return { app, reached: () => reachedHandler };
+ }
+
+ const routes = [
+ "/api/auth/sso/register",
+ "/api/auth/sso/update-provider",
+ "/api/auth/sso/delete-provider",
+ ];
+
+ test.each(routes)("refuses %s to a plain user", async (route) => {
+ const { app, reached } = appFor(["user"]);
+
+ const response = await app.request(`http://openbot.test${route}`, {
+ method: "POST",
+ });
+
+ expect(response.status).toBe(403);
+ // Refused in front of Better Auth, not by it: the plugin would have allowed this.
+ expect(reached()).toBe(false);
+ });
+
+ test.each(routes)("refuses %s to somebody signed out", async (route) => {
+ const { app, reached } = appFor([], false);
+
+ const response = await app.request(`http://openbot.test${route}`, {
+ method: "POST",
+ });
+
+ expect(response.status).toBe(403);
+ expect(reached()).toBe(false);
+ });
+
+ test.each(routes)("lets an administrator through to %s", async (route) => {
+ const { app, reached } = appFor(["admin"]);
+
+ await app.request(`http://openbot.test${route}`, { method: "POST" });
+
+ expect(reached()).toBe(true);
+ });
+
+ // Everything else under /api/auth is Better Auth's own business, including sign-in itself, which
+ // by definition happens before anybody has a role.
+ test("leaves the rest of the auth routes alone", async () => {
+ const { app, reached } = appFor([], false);
+
+ await app.request("http://openbot.test/api/auth/sign-in/social", {
+ method: "POST",
+ });
+
+ expect(reached()).toBe(true);
+ });
+});
diff --git a/server/tests/people-routes.test.ts b/server/tests/people-routes.test.ts
new file mode 100644
index 0000000..cda5bea
--- /dev/null
+++ b/server/tests/people-routes.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, test } from "bun:test";
+import { createApp } from "../src/app";
+import { loadConfig } from "../src/config";
+import type { PeopleStore, Person } from "../src/people/store";
+import { testEnvironment } from "./support/environment";
+
+/**
+ * Who may change who can get in.
+ *
+ * The rules worth pinning are the refusals, because each of them stops a deployment reaching a state
+ * with nobody able to administer it, and none of them is obvious from the route's shape.
+ */
+const ADMIN = {
+ id: "admin-1",
+ email: "admin@openbot.test",
+ name: "An Administrator",
+ image: null,
+};
+
+function person(overrides: Partial = {}): Person {
+ return {
+ id: "u1",
+ email: "member@openbot.test",
+ name: "A Member",
+ image: null,
+ role: "user",
+ providers: ["google"],
+ lastSignedInAt: null,
+ revoked: false,
+ configuredAdmin: false,
+ ...overrides,
+ };
+}
+
+function appWith(
+ people: Person[],
+ role: "admin" | "user" = "admin",
+): {
+ request: (path: string, init?: RequestInit) => Promise;
+ calls: string[];
+} {
+ const calls: string[] = [];
+ const store: PeopleStore = {
+ list: async () => people,
+ find: async (userId) => people.find((entry) => entry.id === userId),
+ setRole: async (userId, next) => {
+ calls.push(`setRole:${userId}:${next}`);
+ },
+ revoke: async (userId, by) => {
+ calls.push(`revoke:${userId}:${by}`);
+ },
+ restore: async (userId) => {
+ calls.push(`restore:${userId}`);
+ },
+ isRevoked: async () => false,
+ };
+
+ const app = createApp(
+ loadConfig(testEnvironment()),
+ {
+ handler: () => new Response(null, { status: 204 }),
+ api: { getSession: async () => ({ user: ADMIN }) },
+ } as never,
+ { rolesForUser: async () => [role] },
+ // Positions 4-18 are the other stores; people is last.
+ ...(Array.from({ length: 15 }) as never[]),
+ store as never,
+ );
+
+ return {
+ request: (path, init) => app.request(`http://openbot.test${path}`, init),
+ calls,
+ };
+}
+
+const json = (body: unknown): RequestInit => ({
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+});
+
+describe("people routes", () => {
+ test("lists everybody for an administrator", async () => {
+ const { request } = appWith([person()]);
+
+ const response = await request("/api/admin/people");
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({ people: [person()] });
+ });
+
+ // A plain user reading the list would learn every colleague's address and when they last signed
+ // in, which is not theirs to have.
+ test("refuses the list to somebody who is not an administrator", async () => {
+ const { request } = appWith([person()], "user");
+
+ expect((await request("/api/admin/people")).status).toBe(403);
+ });
+
+ test("promotes somebody", async () => {
+ const { request, calls } = appWith([person()]);
+
+ const response = await request(
+ "/api/admin/people/u1/role",
+ json({ role: "admin" }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(calls).toEqual(["setRole:u1:admin"]);
+ });
+
+ /**
+ * The configured floor wins over the screen.
+ *
+ * Somebody named in `INITIAL_ADMIN_EMAILS` is promoted again at their next sign-in whatever this
+ * route writes, so allowing the demotion would produce a screen that lies until they come back.
+ */
+ test("refuses to demote somebody the configuration names", async () => {
+ const { request, calls } = appWith([person({ configuredAdmin: true })]);
+
+ const response = await request(
+ "/api/admin/people/u1/role",
+ json({ role: "user" }),
+ );
+
+ expect(response.status).toBe(409);
+ expect(await response.text()).toContain("INITIAL_ADMIN_EMAILS");
+ expect(calls).toEqual([]);
+ });
+
+ test("refuses to remove somebody the configuration names", async () => {
+ const { request, calls } = appWith([person({ configuredAdmin: true })]);
+
+ const response = await request(
+ "/api/admin/people/u1/access",
+ json({ revoked: true }),
+ );
+
+ expect(response.status).toBe(409);
+ expect(calls).toEqual([]);
+ });
+
+ // Both of these lock the person doing it out of the screen that would undo it, and on a
+ // deployment with one administrator that is the whole deployment.
+ test("refuses to let somebody demote themselves", async () => {
+ const { request, calls } = appWith([
+ person({ id: ADMIN.id, email: ADMIN.email, role: "admin" }),
+ ]);
+
+ const response = await request(
+ `/api/admin/people/${ADMIN.id}/role`,
+ json({ role: "user" }),
+ );
+
+ expect(response.status).toBe(409);
+ expect(calls).toEqual([]);
+ });
+
+ test("refuses to let somebody remove their own access", async () => {
+ const { request, calls } = appWith([
+ person({ id: ADMIN.id, email: ADMIN.email, role: "admin" }),
+ ]);
+
+ const response = await request(
+ `/api/admin/people/${ADMIN.id}/access`,
+ json({ revoked: true }),
+ );
+
+ expect(response.status).toBe(409);
+ expect(calls).toEqual([]);
+ });
+
+ test("removes and restores access", async () => {
+ const { request, calls } = appWith([person()]);
+
+ await request("/api/admin/people/u1/access", json({ revoked: true }));
+
+ expect(calls).toEqual([`revoke:u1:${ADMIN.id}`]);
+ });
+
+ test("restores access for somebody already removed", async () => {
+ const { request, calls } = appWith([person({ revoked: true })]);
+
+ await request("/api/admin/people/u1/access", json({ revoked: false }));
+
+ expect(calls).toEqual(["restore:u1"]);
+ });
+
+ test("refuses a role that is not one of the two", async () => {
+ const { request, calls } = appWith([person()]);
+
+ const response = await request(
+ "/api/admin/people/u1/role",
+ json({ role: "owner" }),
+ );
+
+ expect(response.status).toBe(400);
+ expect(calls).toEqual([]);
+ });
+
+ test("answers 404 for somebody who is not here", async () => {
+ const { request } = appWith([]);
+
+ const response = await request(
+ "/api/admin/people/nobody/role",
+ json({ role: "admin" }),
+ );
+
+ expect(response.status).toBe(404);
+ });
+
+ /*
+ * Absent store answers 503, not an empty list. "Nobody has signed in" and "this deployment cannot
+ * tell you" are different answers, and an administrator deciding who has access needs to know
+ * which one they are reading.
+ */
+ test("says so when people are not available at all", async () => {
+ const app = createApp(
+ loadConfig(testEnvironment()),
+ {
+ handler: () => new Response(null, { status: 204 }),
+ api: { getSession: async () => ({ user: ADMIN }) },
+ } as never,
+ { rolesForUser: async () => ["admin"] },
+ );
+
+ const response = await app.request("http://openbot.test/api/admin/people");
+
+ expect(response.status).toBe(503);
+ });
+});
diff --git a/server/tests/roles.test.ts b/server/tests/roles.test.ts
index 9002f8a..8d9a230 100644
--- a/server/tests/roles.test.ts
+++ b/server/tests/roles.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test";
-import { roleForEmail } from "../src/auth/roles";
+import {
+ applyConfiguredAdmin,
+ isConfiguredAdmin,
+ roleForEmail,
+ seedRole,
+ setRole,
+} from "../src/auth/roles";
+import type { Database } from "../src/db/client";
describe("roleForEmail", () => {
test("assigns an admin role to allowlisted addresses without case sensitivity", () => {
@@ -14,3 +21,207 @@ describe("roleForEmail", () => {
);
});
});
+
+/**
+ * Bringing a role in line with the administrator list, on a fake that records the statements.
+ *
+ * A real database would test Drizzle. What matters here is the shape of the write: `user_roles` is
+ * a set with a `(user_id, role)` primary key and the guard takes `admin` if any row says so, so
+ * reconciling has to remove the rows that should not be there rather than only adding one. Insert
+ * alone is what the old create-time hook did, and it could promote but never demote.
+ */
+type Statement = { kind: "delete" | "insert"; role: string };
+
+function recordingDatabase(): {
+ database: Database;
+ statements: Statement[];
+} {
+ const statements: Statement[] = [];
+ const tx = {
+ delete: () => ({
+ where: (condition: unknown) => {
+ // The condition carries the role being kept; what is asserted is that a delete happened at
+ // all and that the insert which follows names the same role.
+ void condition;
+ statements.push({ kind: "delete", role: "" });
+ return Promise.resolve();
+ },
+ }),
+ insert: () => ({
+ values: (row: { role: string }) => ({
+ onConflictDoNothing: () => {
+ statements.push({ kind: "insert", role: row.role });
+ return Promise.resolve();
+ },
+ }),
+ }),
+ };
+
+ const database = {
+ transaction: async (run: (t: typeof tx) => Promise) => {
+ await run(tx);
+ },
+ } as unknown as Database;
+
+ return { database, statements };
+}
+
+describe("setRole", () => {
+ test("replaces the set rather than adding to it", async () => {
+ const { database, statements } = recordingDatabase();
+
+ const role = await setRole(database, "u1", "admin");
+
+ expect(role).toBe("admin");
+ expect(statements).toEqual([
+ { kind: "delete", role: "" },
+ { kind: "insert", role: "admin" },
+ ]);
+ });
+
+ /**
+ * Demotion has to remove the `admin` row, because the guard reads the set and one leftover row
+ * keeps somebody an administrator for ever.
+ */
+ test("removes the admin row when setting somebody back to user", async () => {
+ const { database, statements } = recordingDatabase();
+
+ const role = await setRole(database, "u1", "user");
+
+ expect(role).toBe("user");
+ expect(statements).toEqual([
+ { kind: "delete", role: "" },
+ { kind: "insert", role: "user" },
+ ]);
+ });
+
+ // Both statements together or neither: between them a request on another process would find no
+ // row and be refused with a 403 that reads as a permissions bug rather than a race.
+ test("writes both statements inside one transaction", async () => {
+ let transactions = 0;
+ const database = {
+ transaction: async (run: (t: unknown) => Promise) => {
+ transactions += 1;
+ await run({
+ delete: () => ({ where: () => Promise.resolve() }),
+ insert: () => ({
+ values: () => ({ onConflictDoNothing: () => Promise.resolve() }),
+ }),
+ });
+ },
+ } as unknown as Database;
+
+ await setRole(database, "u1", "admin");
+
+ expect(transactions).toBe(1);
+ });
+});
+
+/**
+ * The configured floor, applied at every sign-in.
+ *
+ * This is the half that has to coexist with an admin screen. `INITIAL_ADMIN_EMAILS` guarantees a way
+ * back in, so an address it names is promoted whenever they sign in; everybody else's role belongs
+ * to whoever administers the deployment, and a sign-in that rewrote it would make that screen lie
+ * the moment they came back.
+ */
+function databaseWithUser(email: string | null): {
+ database: Database;
+ written: string[];
+} {
+ const written: string[] = [];
+ const database = {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ limit: async () => (email === null ? [] : [{ email }]),
+ }),
+ }),
+ }),
+ transaction: async (run: (t: unknown) => Promise) => {
+ await run({
+ delete: () => ({ where: () => Promise.resolve() }),
+ insert: () => ({
+ values: (row: { role: string }) => ({
+ onConflictDoNothing: () => {
+ written.push(row.role);
+ return Promise.resolve();
+ },
+ }),
+ }),
+ });
+ },
+ } as unknown as Database;
+
+ return { database, written };
+}
+
+describe("applyConfiguredAdmin", () => {
+ test("promotes an address the deployment names", async () => {
+ const { database, written } = databaseWithUser("admin@openbot.test");
+
+ await applyConfiguredAdmin(database, "u1", ["admin@openbot.test"]);
+
+ expect(written).toEqual(["admin"]);
+ });
+
+ /**
+ * The reason this is a floor and not the whole answer.
+ *
+ * Somebody promoted from the admin screen is not on the list, and rewriting their role here would
+ * silently undo that promotion the next time they signed in.
+ */
+ test("leaves everybody else exactly as the admin screen set them", async () => {
+ const { database, written } = databaseWithUser("member@openbot.test");
+
+ await applyConfiguredAdmin(database, "u1", ["admin@openbot.test"]);
+
+ expect(written).toEqual([]);
+ });
+
+ test("writes nothing when the deployment names nobody", async () => {
+ const { database, written } = databaseWithUser("admin@openbot.test");
+
+ await applyConfiguredAdmin(database, "u1", []);
+
+ expect(written).toEqual([]);
+ });
+
+ test("does nothing for a session whose user is not there", async () => {
+ const { database, written } = databaseWithUser(null);
+
+ await applyConfiguredAdmin(database, "u1", ["admin@openbot.test"]);
+
+ expect(written).toEqual([]);
+ });
+});
+
+describe("seedRole", () => {
+ test("starts an address on the list as an administrator", async () => {
+ const { database, written } = databaseWithUser("admin@openbot.test");
+
+ await seedRole(database, "u1", "admin@openbot.test", [
+ "admin@openbot.test",
+ ]);
+
+ expect(written).toEqual(["admin"]);
+ });
+
+ test("starts everybody else as a user", async () => {
+ const { database, written } = databaseWithUser("member@openbot.test");
+
+ await seedRole(database, "u1", "member@openbot.test", [
+ "admin@openbot.test",
+ ]);
+
+ expect(written).toEqual(["user"]);
+ });
+});
+
+describe("isConfiguredAdmin", () => {
+ test("ignores case and surrounding space, on both sides", () => {
+ expect(
+ isConfiguredAdmin(" Admin@OpenBot.test ", [" admin@openbot.TEST "]),
+ ).toBe(true);
+ });
+});
diff --git a/server/tests/support/environment.ts b/server/tests/support/environment.ts
index 6912067..60725d9 100644
--- a/server/tests/support/environment.ts
+++ b/server/tests/support/environment.ts
@@ -17,6 +17,8 @@ export function testEnvironment(
GOOGLE_OAUTH_CLIENT_SECRET: "google-client-secret",
BETTER_AUTH_SECRET: "a-long-enough-local-development-auth-secret",
BETTER_AUTH_URL: "http://localhost:3001",
+ // Required whenever a provider is configured: nothing else grants the administrator role.
+ INITIAL_ADMIN_EMAILS: "admin@openbot.test",
// Required. See server/src/config.ts: there is no runtime without Intelligence.
INTELLIGENCE_API_URL: "http://localhost:7100",
INTELLIGENCE_GATEWAY_WS_URL: "ws://localhost:7103",
diff --git a/server/tests/tenant-package.test.ts b/server/tests/tenant-package.test.ts
index 98a257d..3c4e324 100644
--- a/server/tests/tenant-package.test.ts
+++ b/server/tests/tenant-package.test.ts
@@ -222,15 +222,15 @@ describe("tenant YAML validation", () => {
knowledge: "sources: []",
themeCss: ":root { --primary: oklch(0.32 0.09 250); }",
}),
- ["google"],
);
+ // Brand only. Which providers are configured is answered at runtime by /api/capabilities,
+ // because this is compiled into a build that knows nothing about the deployment running it.
expect(configuration).toEqual({
brand: {
tenantId: "fintech",
productName: "Ledgerline",
},
- auth: { providers: ["google"] },
});
});