diff --git a/apps/chat-bot/src/relay/run.ts b/apps/chat-bot/src/relay/run.ts index 164aa3084b..aa0e994ea8 100644 --- a/apps/chat-bot/src/relay/run.ts +++ b/apps/chat-bot/src/relay/run.ts @@ -7,7 +7,13 @@ * the database, and none of it belongs on the path Cloudflare evaluates when it validates the * uploaded script. */ -import { ConnectorCredentials, type ChatConnector, type InboundEvent } from "@maple/chat-platform" +import { + ConnectorCredentials, + WORKSPACE_CREDENTIALS, + type ChatConnector, + type ConnectorConfig, + type InboundEvent, +} from "@maple/chat-platform" import { connectors } from "@maple/chat-platform/connectors" import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" import { chatSessionStub } from "@maple/domain/chat-session-stub" @@ -15,9 +21,10 @@ import type { ChatConnectorId, OrgId } from "@maple/domain/primitives" import type { IntegrationsPersistenceError } from "@maple/domain/http" import { workerTelemetryConfig } from "@maple/infra/worker-telemetry" import { workerEnvLayer } from "@maple/infra/worker-runtime" -import { Cause, Effect, Layer, Option } from "effect" +import { Cause, Effect, Layer, Option, Schema } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { summarizeCause } from "@maple/backend/platform/describe-cause" +import { parseBase64Aes256GcmKey } from "@maple/backend/platform/Crypto" import { chatChartImageUrl } from "@maple/backend/services/chat/chat-chart" import { Database } from "@maple/backend/platform/DatabaseLive" import { layerPg } from "@maple/backend/platform/DatabasePgLive" @@ -51,6 +58,39 @@ const APP_BASE_URL_FALLBACK = "https://app.maple.dev" const appBaseUrl = (env: Record): string => setting(env, "MAPLE_APP_BASE_URL") ?? APP_BASE_URL_FALLBACK +/** `MAPLE_INGEST_KEY_ENCRYPTION_KEY` is set on this deployment but is not a usable key. */ +class CredentialKeyUnusable extends Schema.TaggedError()( + "@maple/chat-bot/CredentialKeyUnusable", + { message: Schema.String }, +) {} + +/** + * The key a connector's per-workspace credential was sealed with, or `null` where this deployment + * binds none. + * + * Optional because most of this Worker does not need it: a connector whose credential is one + * deployment-wide secret stores nothing to open, and a stage without the key runs those normally. + * A workspace that DID store one then fails its lookup rather than resolving without its token — + * see `chat-workspace-rows.ts`. + */ +const credentialKey = (env: Record): Effect.Effect => + parseBase64Aes256GcmKey( + setting(env, "MAPLE_INGEST_KEY_ENCRYPTION_KEY") ?? "", + (message) => new CredentialKeyUnusable({ message }), + ).pipe( + // Absent is the ordinary case on a stage running no connector that stores a credential, so + // it is not an error — but a key that IS set and unusable is a deployment mistake that + // would otherwise surface only as an unreadable workspace on every mention, reason nowhere. + Effect.tapError((error) => + setting(env, "MAPLE_INGEST_KEY_ENCRYPTION_KEY") === undefined + ? Effect.void + : Effect.logError("Chat workspace credential key is unusable").pipe( + Effect.annotateLogs({ "error.type": error._tag, "error.message": error.message }), + ), + ), + Effect.orElseSucceed(() => null), + ) + export interface RelayHost { readonly env: Record /** Whether this conversation has already been told that its workspace is not linked. */ @@ -96,32 +136,38 @@ const lookupFailed = ), ) -const ports = ( +/** + * The workspace this event belongs to: which org, which Maple user the clicker linked, and the + * credential the connector posts with — in ONE connection. + * + * Sequential rather than parallel because the second question needs the first one's answer: a link + * is per org, and the org is what the workspace names. + * + * The credential rides along because it comes out of the same row, and reading that row twice — + * once to answer the relay and once to build the transport — would be two Postgres connections for + * one mention. + */ +const lookupWorkspace = ( host: RelayHost, connector: ChatConnector, -): RelayPorts => ({ - outbound: connector.outbound, - supportsIdentity: connector.identity !== undefined, - /** - * The workspace, and — when a caller names a chat account — the Maple user it is linked to, in - * ONE connection. - * - * Sequential rather than parallel because the second question needs the first one's answer: a - * link is per org, and the org is what the workspace names. - */ - resolveWorkspace: (connectorId, workspaceId, externalUserId) => + connectorId: ChatConnectorId, + workspaceId: string, + externalUserId: string | undefined, +) => + Effect.flatMap(credentialKey(host.env), (key) => withDatabase( host.env, Effect.gen(function* () { const database = yield* Database - const workspace = yield* resolveChatWorkspace(database, connectorId, workspaceId) + const workspace = yield* resolveChatWorkspace(database, connectorId, workspaceId, key) + if (Option.isNone(workspace)) return Option.none() + const credentials = workspace.value.credentials // Nothing to link with, or nobody asked: one query. - if ( - Option.isNone(workspace) || - externalUserId === undefined || - connector.identity === undefined - ) { - return Option.map(workspace, (found) => ({ orgId: found.orgId })) + if (externalUserId === undefined || connector.identity === undefined) { + return Option.some({ + relay: { orgId: workspace.value.orgId }, + credentials, + }) } const identity = yield* resolveChatIdentity( database, @@ -129,12 +175,57 @@ const ports = ( connectorId, externalUserId, ) - return Option.some({ - orgId: workspace.value.orgId, - ...(Option.isNone(identity) ? undefined : { linkedUserId: identity.value.userId }), + return Option.some({ + relay: { + orgId: workspace.value.orgId, + ...(Option.isNone(identity) ? undefined : { linkedUserId: identity.value.userId }), + }, + credentials, }) }), ).pipe(lookupFailed(connectorId, "The chat workspace could not be read")), + ) + +/** What the one lookup answers: the relay's half, and the transport's. */ +interface ResolvedWorkspace { + readonly relay: RelayWorkspace + readonly credentials: string | undefined +} + +/** + * The config the connector's transport is built from: what the deployment set, plus this + * workspace's own credential where it stored one. + */ +const connectorCredentials = ( + config: ConnectorConfig, + resolved: Option.Option, +): ConnectorConfig => { + if (Option.isNone(resolved) || resolved.value.credentials === undefined) return config + return new Map(config).set(WORKSPACE_CREDENTIALS, resolved.value.credentials) +} + +const ports = ( + host: RelayHost, + connector: ChatConnector, + lookup: ( + connectorId: ChatConnectorId, + workspaceId: string, + externalUserId: string | undefined, + ) => Effect.Effect, WorkspaceLookupFailed>, +): RelayPorts => ({ + outbound: connector.outbound, + supportsIdentity: connector.identity !== undefined, + /** + * The workspace, and — when a caller names a chat account — the Maple user it is linked to, in + * ONE connection. + * + * Sequential rather than parallel because the second question needs the first one's answer: a + * link is per org, and the org is what the workspace names. + */ + resolveWorkspace: (connectorId, workspaceId, externalUserId) => + Effect.map(lookup(connectorId, workspaceId, externalUserId), (found) => + Option.map(found, (workspace) => workspace.relay), + ), forgetWorkspace: (connectorId, workspaceId) => withDatabase( host.env, @@ -182,21 +273,33 @@ export const runInboundEvent = async (host: RelayHost, event: InboundEvent): Pro const config = resolveConnectorConfig(host.env, connector) if (config._tag === "missing") return - try { - await Effect.runPromise( - relayInboundEvent(event, ports(host, connector)).pipe( - // oxlint-disable-next-line effecttsgo/strict-effect-provide - Effect.provide( - Layer.mergeAll( - FetchHttpClient.layer, - Layer.succeed(ConnectorCredentials)(config.config), - workerEnvLayer(host.env), - telemetry.layer, - ), + // One read for the whole event, memoized: the relay asks which org this is, and the transport is + // built from the credential the same row carries. `Effect.cached` is what keeps that one + // Postgres connection rather than two, without hoisting the read ahead of the relay's own + // decision about whether this message is a turn at all. + await Effect.runPromise( + Effect.gen(function* () { + const lookup = yield* Effect.cached( + lookupWorkspace( + host, + connector, + event.connector, + event.workspaceId, + // Only a click names a person; a message is answered for the workspace. + event.type === "action" ? event.actor.id : undefined, ), - ), - ) - } finally { - await telemetry.flush(host.env).catch(() => undefined) - } + ) + const resolved = yield* Effect.orElseSucceed(lookup, () => Option.none()) + yield* relayInboundEvent( + event, + ports(host, connector, () => lookup), + ).pipe(Effect.provideService(ConnectorCredentials, connectorCredentials(config.config, resolved))) + }).pipe( + // oxlint-disable-next-line effecttsgo/strict-effect-provide + Effect.provide(Layer.mergeAll(FetchHttpClient.layer, workerEnvLayer(host.env), telemetry.layer)), + // On the fiber rather than in a `finally`: the flush is what exports this event's spans, + // so it belongs to the same interruption and failure handling they do. + Effect.ensuring(Effect.promise(() => telemetry.flush(host.env).catch(() => undefined))), + ), + ) } diff --git a/apps/chat-bot/src/worker.ts b/apps/chat-bot/src/worker.ts index 67b3852e91..a2376b54b8 100644 --- a/apps/chat-bot/src/worker.ts +++ b/apps/chat-bot/src/worker.ts @@ -22,11 +22,11 @@ * the agent's chat session and the answer is streamed back. The ingress half * never waits for any of that. * - * **No public hostname.** The socket half dials out and needs none, and no - * webhook connector is registered yet — so a custom domain would be DNS, a - * certificate and a `MapleDomains` entry bought for a route nothing calls. Under - * `bun dev` the portless route reaches it; the first webhook connector is what - * should buy the hostname. + * **One public hostname, on production instances only** (`domains.chat`). A webhook connector's + * platform is configured with a request URL inside the vendor's own application, and that URL has + * to keep working across deploys — which is what buys the custom domain the socket half never + * needed. A dev stage reaches the same route through portless, and a PR preview gets no hostname: + * a connector there would have no credentials and no database to resolve a workspace in. */ import { connectors } from "@maple/chat-platform/connectors" import { @@ -68,6 +68,10 @@ const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDom // reply carries its charts as text, which is what `chatChartImageUrl` answers `null` for. plainWithDefault("MAPLE_APP_BASE_URL", `https://${domains.web ?? "app.maple.dev"}`), optionalSecret("MAPLE_SHARE_TOKEN_HMAC_KEY"), + // Opens the credential a connector's install sealed against a workspace row. Optional, and + // only a connector that stores one needs it: without the key such a workspace fails its + // lookup, and every other connector runs normally. + optionalSecret("MAPLE_INGEST_KEY_ENCRYPTION_KEY"), ) /** @@ -87,8 +91,11 @@ const props = Effect.gen(function* () { placement: resolveWorkerPlacement(region), // Under `bun dev`: a sticky port the app's route follows. dev: workerDev("chat-bot"), - // See the module comment: nothing calls in from the public internet yet. + // A webhook connector's platform calls this hostname; see the module comment. A custom + // domain rather than a route, so DNS and the edge certificate come with it, and rather than + // `workers.dev`, whose URL carries the account subdomain and moves with the account. workersDev: false, + domain: domains.chat, // `devEnv` last, so `.env.local` cannot override the inter-app URLs. env: { // Cross-script reference to the chat Durable Object the AI Worker hosts: a mention diff --git a/docs/chat-connector-cutover.md b/docs/chat-connector-cutover.md new file mode 100644 index 0000000000..4edcbaf09a --- /dev/null +++ b/docs/chat-connector-cutover.md @@ -0,0 +1,105 @@ +# Cutting over from the standalone chat agent + +`apps/slack-agent` is the bot Maple has been running: its own Railway service, its own app, its own +event URL. The chat-platform connector in `packages/chat-platform/src/connectors/slack/` replaces +it, on Cloudflare, behind the same `ChatConnector` contract every other platform uses. + +This is the ops runbook for running both at once and then retiring the old one. It is not a +migration: there is nothing to move. The two have separate applications, separate tables and +separate credentials, so the cutover is "stop pointing at one, start pointing at the other", and the +rollback is the same sentence backwards. + +**Deleting `apps/slack-agent` is not part of the connector's own change.** It is step 7 here, after +step 6 has held. + +## Why they can run side by side + +| | the standalone agent | the connector | +| --- | --- | --- | +| Where it runs | Railway | `apps/chat-bot` (Cloudflare) | +| Which app | its own | a new one — see the connector's README | +| Event URL | its own host | `https://chat.maple.dev/connectors/slack/webhook` | +| Workspace ↔ org | its own store | `chat_workspaces`, one row per linked workspace | +| Credentials | its own | `chat_workspaces.credentials_*`, sealed per workspace | + +Nothing is shared, so both can be installed in the same workspace at the same time. They will both +answer a mention — which is the point of step 3, and the reason the cutover itself is one switch. + +Note the third, unrelated thing also called Slack here: the alert-delivery integration +(`slack_workspaces`, `SlackIntegrationService`) is a fourth application again, is not part of this +cutover, and keeps running throughout. + +## The cutover + +1. **Create the new application and put its secrets on the deployments.** The connector's README has + the app manifest and the three secrets — client id and secret on api, signing secret on chat-bot + — plus `MAPLE_INGEST_KEY_ENCRYPTION_KEY` on chat-bot, which is what opens the per-workspace token. + +2. **Deploy.** `apps/chat-bot` takes `chat.maple.dev` on production instances; a dev stage reaches + the same route through portless and a PR preview gets no hostname. Check + `https://chat.maple.dev/connectors/slack/webhook` answers **503** before the secrets land and + **400** after (an unsigned request is a rejected request — a 404 means the connector is not in + this build, and a 200 means something is very wrong). + +3. **Install it into one workspace and run the smoke checklist**, with the old agent still running. + This is where the facts that only a live install can confirm get confirmed: + + - saving the request URL succeeds (that is the `url_verification` handshake); + - **a top-level mention** in a public channel is answered in a thread on that message; + - **a mention inside an existing thread** is answered in that same thread — and lands on the + same session as the first question, not a new one; + - **a reply in the bot's thread without a mention** is answered, and the answer shows the bot + read the messages above it — the thread is one the bot opened, which is what makes an + un-addressed message in it still a turn; + - **an un-addressed message in a channel the bot was merely invited to** is NOT answered; + - **an approval card** renders with both buttons, pressing one applies the change, and the + card then keeps its line and loses its buttons; + - **the identity check**, which is what would let Slack approvals run as the person rather than + as the org: sign the same user in through Slack's OpenID Connect flow and compare the `sub` + it returns against the `user.id` on their `block_actions` payload. If they match, the + connector's `identity` can be implemented (see its README); nothing depends on the answer + today, so this is evidence-gathering rather than a gate; + - **a private channel, a DM and a group DM** each behave like the public channel; + - **formatting**: a turn with bold, italics, a link, a bullet list, a blockquote, a table and a + code block is readable — the table arrives as a fenced block, and nothing renders as raw + markdown; + - **nothing pages the workspace**: ask the bot to repeat `` and `[urgent](!channel)` + back, and confirm neither notifies anybody; + - **removing the app** from the workspace unlinks its row (`app_uninstalled` → + `workspace-removed`); + - **no token anywhere**: grep the deploy's logs and spans for `xoxb-` and find nothing. + +4. **Install and verify the connector in EVERY workspace the old app is in**, not just the one from + step 3. Step 5 is app-wide and cannot be done per workspace, so a workspace still on the old + agent when it runs simply loses its bot. `chat_workspaces` is the list of what has been linked; + the old application's own install list is what it has to be checked against. + +5. **Turn the old app's event subscriptions OFF.** In the OLD application's configuration, disable + Event Subscriptions — do not point the request URL at something that fails. Slack retries a + delivery its endpoint rejects and **disables an app's subscriptions after enough of them**, so + an intentionally broken URL ends in the same place as switching them off, except that it gets + there by itself, at a time nobody chose, and re-entering the URL afterwards does not bring + delivery back until an operator re-enables the subscriptions by hand. + + This is **app-wide**: Slack applies a subscription change across every team the app is installed + in, which is why step 4 comes first. The Railway agent keeps running and keeps its own + configuration; it simply stops being sent anything, everywhere, at once. + +6. **Let it sit.** A week of real use is enough to find what a checklist does not. If the connector + has to be backed out, re-enable the old app's event subscriptions — the old agent has been + running untouched the whole time, and its request URL never moved. + +7. **Retire the old agent**, in this order: uninstall the old application from every workspace, stop + the Railway service, then delete `apps/slack-agent` and its deploy configuration in a change of + its own. + +## What each rollback costs + +| Step reached | To undo | +| --- | --- | +| 2 | Nothing is live; remove the secrets or leave them, the connector is skipped without them. | +| 3 | Uninstall the new app from the workspace; its `chat_workspaces` row goes with it. | +| 4 | Same, for each workspace. The old app is still serving all of them. | +| 5 | Re-enable the old app's event subscriptions — app-wide, so every workspace comes back together. | +| 6 | Same. | +| 7 | The old agent is gone — from here, forward only. | diff --git a/docs/infra.md b/docs/infra.md index c31da883c4..8c1279089a 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -314,12 +314,21 @@ impl)` over the plain `ChatSession` class — the outer Effect resolves state an database (PR previews) the lookup fails, is logged, and the mention goes unanswered rather than being told the workspace is unlinked. - It has **no public hostname**: the socket half dials out, and no webhook connector is - registered yet, so a custom domain would be DNS plus a certificate bought for a route - nothing calls. Under `bun dev chat-bot` the portless route reaches the webhook path. The - Worker is inert on a stage with no connector credentials — every connector key is bound - optional, and a connector without its configuration is skipped with one log line, so no - socket is opened and no turn is ever relayed. + It takes **one public hostname on production instances** (`domains.chat` — + `chat.maple.dev`, `chat.eu.maple.dev`): a webhook connector's platform is configured with a + request URL inside the vendor's own application, and that URL has to keep working across + deploys, which is what buys the custom domain the socket half never needed. A dev stage + reaches the same route through portless and a PR preview gets none — a connector there would + have neither credentials nor a database to resolve a workspace in. The Worker is inert on a + stage with no connector credentials: every connector key is bound optional, and a connector + without its configuration is skipped with one log line, so no socket is opened, the webhook + route answers 503, and no turn is ever relayed. + + A connector whose install mints a credential per workspace (rather than using one + deployment-wide secret) has it sealed into `chat_workspaces.credentials_{ciphertext,iv,tag}`, + AAD-bound to `(org_id, connector, external_workspace_id)`. That is why chat-bot also binds + `MAPLE_INGEST_KEY_ENCRYPTION_KEY`, optionally: without it such a workspace fails its lookup + and the mention goes unanswered, while every other connector runs normally. - **The sandbox Worker** (`sandbox`): the one Worker in the fleet whose own module is its bundle entry. It hosts Cloudflare's Sandbox Durable Object (`@cloudflare/sandbox`), diff --git a/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts b/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts index 8310ec0ca4..8acb9cd0e7 100644 --- a/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts +++ b/packages/backend/src/services/integrations/ChatWorkspaceService.test.ts @@ -86,6 +86,9 @@ const testConnector: ChatConnector = { Effect.succeed({ externalWorkspaceId: params.get("workspace") ?? "workspace-default", name: params.get("name") ?? "Test workspace", + // A connector whose platform mints a per-workspace secret returns it here as one + // opaque string; one that does not returns nothing, and both must work. + ...(params.has("credentials") ? { credentials: params.get("credentials") ?? "" } : undefined), }), decodeSettings: (input: ChatWorkspaceSettings) => input.approver === undefined || /^[a-z]+$/.test(input.approver) @@ -454,6 +457,48 @@ describe("ChatWorkspaceService", () => { }), ) + it.effect("stores a connector's per-workspace credential sealed, and hands it back decrypted", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + yield* Effect.gen(function* () { + const chat = yield* ChatWorkspaceService + const { url } = yield* chat.beginInstall(ORG, USER, TEST_CONNECTOR, CALLBACK) + const params = callback(stateFrom(url), "workspace-cred", "Acme") + params.set("credentials", '{"token":"a-workspaces-own-token"}') + yield* chat.completeInstall(TEST_CONNECTOR, params) + + const resolved = yield* chat.resolve(TEST_CONNECTOR, "workspace-cred") + assert.strictEqual( + Option.getOrUndefined(resolved)?.credentials, + '{"token":"a-workspaces-own-token"}', + ) + // Sealed at rest: the column holds no plaintext for anyone reading the table. + const row = yield* Effect.promise(() => + queryFirstRow<{ credentials_ciphertext: string | null }>( + testDb, + "select credentials_ciphertext from chat_workspaces where external_workspace_id = 'workspace-cred'", + ), + ) + assert.isString(row?.credentials_ciphertext) + assert.notInclude(row?.credentials_ciphertext ?? "", "token") + }).pipe(Effect.provide(makeLayer(testDb))) + }), + ) + + it.effect("resolves a connector that stores no credential with none", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + yield* Effect.gen(function* () { + const chat = yield* ChatWorkspaceService + const { url } = yield* chat.beginInstall(ORG, USER, TEST_CONNECTOR, CALLBACK) + yield* chat.completeInstall(TEST_CONNECTOR, callback(stateFrom(url), "workspace-plain")) + const resolved = yield* chat.resolve(TEST_CONNECTOR, "workspace-plain") + assert.isTrue(Option.isSome(resolved)) + assert.isUndefined(Option.getOrUndefined(resolved)?.credentials) + }).pipe(Effect.provide(makeLayer(testDb))) + }), + ) + it.effect("refuses an account link for a connector that cannot say who clicked", () => Effect.gen(function* () { const testDb = createTestDb(trackedDbs) diff --git a/packages/backend/src/services/integrations/ChatWorkspaceService.ts b/packages/backend/src/services/integrations/ChatWorkspaceService.ts index b4cf560133..add72f2eac 100644 --- a/packages/backend/src/services/integrations/ChatWorkspaceService.ts +++ b/packages/backend/src/services/integrations/ChatWorkspaceService.ts @@ -17,10 +17,12 @@ import { chatWorkspaces, type ChatWorkspaceRow } from "@maple/db" import { isConnectorConfigured, type ChatConnector, type ChatWorkspaceSettings } from "@maple/chat-platform" import { connectors } from "@maple/chat-platform/connectors" import { and, asc, eq } from "drizzle-orm" -import { Array as Arr, Clock, Context, Effect, Layer, Option, Schema } from "effect" +import { Array as Arr, Clock, Context, Effect, Layer, Option, Redacted, Schema } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { parseBase64Aes256GcmKey } from "@maple/backend/platform/Crypto" import { Database, type DatabaseError } from "@maple/backend/platform/DatabaseLive" import { Env } from "@maple/backend/platform/Env" +import { sealChatWorkspaceCredentials } from "@maple/backend/services/integrations/chat-workspace-credentials" import { dateToMs, msToDate } from "@maple/backend/platform/time" import { OAuthStateRepository } from "@maple/backend/services/auth/OAuthStateRepository" import { @@ -219,6 +221,20 @@ const make: Effect.Effect< const config = env.CHAT_CONNECTOR_CONFIG + /** + * The key the per-workspace credential envelope is sealed and opened with. + * + * Read lazily rather than at layer build: only a connector whose install mints a credential + * needs it, and a deployment without one must still list, link and unlink everything else. + */ + const credentialKey = parseBase64Aes256GcmKey( + Redacted.value(env.MAPLE_INGEST_KEY_ENCRYPTION_KEY), + (message) => + new IntegrationsConfigurationError({ + message: `Chat workspace credentials cannot be stored on this deployment: ${message}`, + }), + ) + const toPersistenceError = (error: DatabaseError | OAuthStatePersistenceError) => new IntegrationsPersistenceError({ message: `${error._tag}: ${error.message}` }) @@ -405,6 +421,34 @@ const make: Effect.Effect< }), ) + // A connector that minted a per-workspace secret hands it over as one opaque string; it is + // sealed here and never read above the connector that wrote it. A re-install replaces it, + // because the platform issued a new one and the old one is what was just revoked. + const credentials = installed.credentials + const sealed = + credentials === undefined + ? null + : yield* Effect.flatMap(credentialKey, (key) => + sealChatWorkspaceCredentials( + credentials, + key, + { + orgId, + connector: connectorId, + externalWorkspaceId: installed.externalWorkspaceId, + }, + (message) => + new IntegrationsPersistenceError({ + message: `The chat workspace credential could not be stored: ${message}`, + }), + ), + ) + const credentialColumns = { + credentialsCiphertext: sealed?.ciphertext ?? null, + credentialsIv: sealed?.iv ?? null, + credentialsTag: sealed?.tag ?? null, + } + const inserted = yield* database .execute((db) => db @@ -416,15 +460,16 @@ const make: Effect.Effect< externalWorkspaceId: installed.externalWorkspaceId, name: installed.name, settings: {}, + ...credentialColumns, createdAt: msToDate(now), }) .onConflictDoUpdate({ target: [chatWorkspaces.connector, chatWorkspaces.externalWorkspaceId], - // A re-install refreshes the org's own row (the name may have - // changed) and keeps its settings. Another org's row is left alone: + // A re-install refreshes the org's own row (the name and the credential may + // both have changed) and keeps its settings. Another org's row is left alone: // the update is skipped, and zero returned rows is the conflict. setWhere: eq(chatWorkspaces.orgId, orgId), - set: { name: installed.name }, + set: { name: installed.name, ...credentialColumns }, }) .returning({ id: chatWorkspaces.id }), ) @@ -657,7 +702,18 @@ const make: Effect.Effect< externalWorkspaceId: string, ) { yield* Effect.annotateCurrentSpan({ "chat.connector": connectorId }) - const resolved = yield* resolveChatWorkspace(database, connectorId, externalWorkspaceId) + // A deployment with no usable key resolves the workspace without its credential rather than + // failing the lookup: everything that does not need one keeps working, and the connector + // that does reports it cannot post. + const key = yield* credentialKey.pipe( + Effect.tapError((error) => + Effect.logError("Chat workspace credential key is unusable").pipe( + Effect.annotateLogs({ "error.type": error._tag, "error.message": error.message }), + ), + ), + Effect.orElseSucceed(() => null), + ) + const resolved = yield* resolveChatWorkspace(database, connectorId, externalWorkspaceId, key) // The one cross-tenant lookup here — the resolved org belongs on the span. if (Option.isSome(resolved)) yield* Effect.annotateCurrentSpan({ orgId: resolved.value.orgId }) return resolved diff --git a/packages/backend/src/services/integrations/chat-workspace-credentials.test.ts b/packages/backend/src/services/integrations/chat-workspace-credentials.test.ts new file mode 100644 index 0000000000..269cb6e705 --- /dev/null +++ b/packages/backend/src/services/integrations/chat-workspace-credentials.test.ts @@ -0,0 +1,85 @@ +/** + * The envelope, on its own. + * + * The round trip is the cheap half; the binding is the half worth a test — the whole reason the + * AAD exists is that an attacker with database writes must not be able to move a working + * ciphertext onto a row it was not written for. + */ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import { + openChatWorkspaceCredentials, + sealChatWorkspaceCredentials, + storedCredentials, +} from "./chat-workspace-credentials" + +const KEY = Buffer.alloc(32, 7) +const OTHER_KEY = Buffer.alloc(32, 9) + +const REF = { orgId: "org_1", connector: "testchat", externalWorkspaceId: "workspace-1" } + +const SECRET = '{"token":"a-workspaces-own-token"}' + +const fail = (message: string) => new Error(message) + +const seal = (ref = REF, key = KEY) => sealChatWorkspaceCredentials(SECRET, key, ref, fail) + +const open = ( + sealed: { readonly ciphertext: string; readonly iv: string; readonly tag: string }, + ref = REF, + key = KEY, +) => openChatWorkspaceCredentials(sealed, key, ref, fail) + +describe("chat workspace credentials", () => { + it.effect("round-trips a connector's own secret", () => + Effect.gen(function* () { + const sealed = yield* seal() + assert.notInclude(sealed.ciphertext, "token") + assert.strictEqual(yield* open(sealed), SECRET) + }), + ) + + it.effect("mints a fresh nonce every time, so two rows never share one", () => + Effect.gen(function* () { + const first = yield* seal() + const second = yield* seal() + assert.notStrictEqual(first.iv, second.iv) + assert.notStrictEqual(first.ciphertext, second.ciphertext) + }), + ) + + it.effect("refuses to open a ciphertext moved onto another org, connector or workspace", () => + Effect.gen(function* () { + const sealed = yield* seal() + for (const ref of [ + { ...REF, orgId: "org_2" }, + { ...REF, connector: "otherchat" }, + { ...REF, externalWorkspaceId: "workspace-2" }, + ]) { + assert.isTrue(yield* Effect.isFailure(open(sealed, ref))) + } + }), + ) + + it.effect("refuses to open it with a different deployment's key", () => + Effect.gen(function* () { + const sealed = yield* seal() + assert.isTrue(yield* Effect.isFailure(open(sealed, REF, OTHER_KEY))) + }), + ) + + it("reads a row with no credential, and a half-written one, as no credential", () => { + assert.isNull( + storedCredentials({ credentialsCiphertext: null, credentialsIv: null, credentialsTag: null }), + ) + // Two of three columns is a row nobody can decrypt; treating it as absent is the same + // outcome as trying and failing, one branch earlier. + assert.isNull( + storedCredentials({ credentialsCiphertext: "c", credentialsIv: "i", credentialsTag: null }), + ) + assert.deepStrictEqual( + storedCredentials({ credentialsCiphertext: "c", credentialsIv: "i", credentialsTag: "t" }), + { ciphertext: "c", iv: "i", tag: "t" }, + ) + }) +}) diff --git a/packages/backend/src/services/integrations/chat-workspace-credentials.ts b/packages/backend/src/services/integrations/chat-workspace-credentials.ts new file mode 100644 index 0000000000..581a68a762 --- /dev/null +++ b/packages/backend/src/services/integrations/chat-workspace-credentials.ts @@ -0,0 +1,82 @@ +/** + * The envelope around a chat workspace's own credential. + * + * A connector whose install mints a per-workspace secret hands it back as one opaque string + * (`ChatInstallResult.credentials`); this is where that string is sealed on the way into the row + * and opened on the way back out. Nothing here reads the plaintext — it is the connector's own + * encoding, and the only thing above a connector that touches it is this file, which treats it as + * bytes. + * + * Deliberately its own module rather than a method on the service: the Worker that answers a + * mention opens the envelope (`chat-workspace-rows.ts`) and never links anything, so it must not + * take the install service's OAuth state, HTTP client and connector config just to read a token. + */ +import { Effect } from "effect" +import { decryptAes256Gcm, encryptAes256Gcm, type EncryptedValue } from "@maple/backend/platform/Crypto" + +/** + * What the envelope is authenticated against. + * + * Authenticated but not stored, so it binds the ciphertext to its row: an attacker holding only + * database writes cannot relocate an `(iv, ciphertext, tag)` triple onto another org's row, onto + * another connector's row, or onto the same connector's row for a different workspace — which is + * exactly the move that would point one team's bot token at another team's conversations. + */ +export const chatWorkspaceCredentialsAad = ( + orgId: string, + connector: string, + externalWorkspaceId: string, +): Buffer => Buffer.from(`chat_workspaces:v1:${orgId}:${connector}:${externalWorkspaceId}`, "utf8") + +export interface ChatWorkspaceCredentialsRef { + readonly orgId: string + readonly connector: string + readonly externalWorkspaceId: string +} + +/** Seal a connector's credential string for storage. */ +export const sealChatWorkspaceCredentials = ( + credentials: string, + encryptionKey: Buffer, + ref: ChatWorkspaceCredentialsRef, + onError: (message: string) => E, +): Effect.Effect => + encryptAes256Gcm( + credentials, + encryptionKey, + onError, + chatWorkspaceCredentialsAad(ref.orgId, ref.connector, ref.externalWorkspaceId), + ) + +/** Open a stored envelope. Fails when the key, the row or the AAD no longer match. */ +export const openChatWorkspaceCredentials = ( + encrypted: EncryptedValue, + encryptionKey: Buffer, + ref: ChatWorkspaceCredentialsRef, + onError: (message: string) => E, +): Effect.Effect => + decryptAes256Gcm( + encrypted, + encryptionKey, + onError, + chatWorkspaceCredentialsAad(ref.orgId, ref.connector, ref.externalWorkspaceId), + ) + +/** + * The three columns as one value, or `null` when the row carries no credential. + * + * A half-written triple is `null` too: two of three columns is a row nobody can decrypt, and + * treating it as "no credential" is the same outcome as trying and failing, one branch earlier. + */ +export const storedCredentials = (row: { + readonly credentialsCiphertext: string | null + readonly credentialsIv: string | null + readonly credentialsTag: string | null +}): EncryptedValue | null => + row.credentialsCiphertext === null || row.credentialsIv === null || row.credentialsTag === null + ? null + : { + ciphertext: row.credentialsCiphertext, + iv: row.credentialsIv, + tag: row.credentialsTag, + } diff --git a/packages/backend/src/services/integrations/chat-workspace-rows.ts b/packages/backend/src/services/integrations/chat-workspace-rows.ts index 670916fce3..8d9ae10622 100644 --- a/packages/backend/src/services/integrations/chat-workspace-rows.ts +++ b/packages/backend/src/services/integrations/chat-workspace-rows.ts @@ -7,18 +7,29 @@ * that reads one row per mention. The service calls these same functions, so there is one query * per question rather than two. */ +import { Buffer } from "node:buffer" import { chatWorkspaces } from "@maple/db" import type { ChatWorkspaceSettings } from "@maple/chat-platform" import { ChatConnectorId, ChatWorkspaceId, IntegrationsPersistenceError, OrgId } from "@maple/domain/http" import { and, eq } from "drizzle-orm" import { Effect, Option, Schema } from "effect" import type { DatabaseApi, DatabaseError } from "@maple/backend/platform/DatabaseLive" +import { + openChatWorkspaceCredentials, + storedCredentials, +} from "@maple/backend/services/integrations/chat-workspace-credentials" /** What the bot Worker needs to act on an inbound event. */ export interface ChatWorkspaceResolution { readonly orgId: OrgId readonly workspaceId: ChatWorkspaceId readonly settings: ChatWorkspaceSettings + /** + * The connector's own per-workspace secret, decrypted, or `undefined` for a connector that + * stored none. The host puts it in `ConnectorCredentials` under `WORKSPACE_CREDENTIALS`; it is + * the connector's encoding and nothing here reads it. + */ + readonly credentials: string | undefined } const decodeStored = Schema.decodeUnknownEffect( @@ -41,37 +52,67 @@ const unreadable = (message: string) => new IntegrationsPersistenceError({ messa * the table, and an unreadable row is this function's failure rather than a bad org id reaching a * session id. */ -export const resolveChatWorkspace = ( +export const resolveChatWorkspace = Effect.fn("resolveChatWorkspace")(function* ( database: DatabaseApi, connectorId: ChatConnectorId, externalWorkspaceId: string, -): Effect.Effect, IntegrationsPersistenceError> => - Effect.gen(function* () { - const rows = yield* database - .execute((db) => - db - .select() - .from(chatWorkspaces) - .where( - and( - eq(chatWorkspaces.connector, connectorId), - eq(chatWorkspaces.externalWorkspaceId, externalWorkspaceId), - ), - ) - .limit(1), - ) - .pipe(Effect.mapError(persistenceError)) - const row = rows[0] - if (row === undefined) return Option.none() - const stored = yield* decodeStored(row).pipe( - Effect.mapError((error) => unreadable(`Stored chat workspace is unreadable: ${error.message}`)), + /** + * The key the credential envelope was sealed with, or `null` on a deployment that has none. + * A row that carries a credential no key can open fails the resolution rather than resolving to + * a workspace whose connector then cannot post — the second is a bot that answers nothing and + * says why nowhere. No default: both callers hold a key or know they do not, and a forgotten + * argument would read every credential row as unopenable. + */ + encryptionKey: Buffer | null, +) { + yield* Effect.annotateCurrentSpan({ "chat.connector": connectorId }) + const rows = yield* database + .execute((db) => + db + .select() + .from(chatWorkspaces) + .where( + and( + eq(chatWorkspaces.connector, connectorId), + eq(chatWorkspaces.externalWorkspaceId, externalWorkspaceId), + ), + ) + .limit(1), ) + .pipe(Effect.mapError(persistenceError)) + const row = rows[0] + if (row === undefined) return Option.none() + const stored = yield* decodeStored(row).pipe( + Effect.mapError((error) => unreadable(`Stored chat workspace is unreadable: ${error.message}`)), + ) + const sealed = storedCredentials(row) + if (sealed === null) { return Option.some({ orgId: stored.orgId, workspaceId: stored.id, settings: stored.settings, + credentials: undefined, }) + } + if (encryptionKey === null) { + return yield* Effect.fail( + unreadable("This chat workspace stores a credential and this deployment has no key for it"), + ) + } + const credentials = yield* openChatWorkspaceCredentials( + sealed, + encryptionKey, + { orgId: stored.orgId, connector: connectorId, externalWorkspaceId }, + // The message never carries the cause: everything below it is key material and ciphertext. + (message) => unreadable(`Stored chat workspace credential is unreadable: ${message}`), + ) + return Option.some({ + orgId: stored.orgId, + workspaceId: stored.id, + settings: stored.settings, + credentials, }) +}) /** * Drop the link for a workspace the bot was removed from, and answer whether there was one. diff --git a/packages/chat-platform/src/connectors/index.ts b/packages/chat-platform/src/connectors/index.ts index 1e1d587742..78e28d9bb5 100644 --- a/packages/chat-platform/src/connectors/index.ts +++ b/packages/chat-platform/src/connectors/index.ts @@ -10,5 +10,6 @@ * the one an incoming event belongs to by id. */ import { discord } from "./discord" +import { slack } from "./slack" -export const connectors = [discord] +export const connectors = [discord, slack] diff --git a/packages/chat-platform/src/connectors/manifests.ts b/packages/chat-platform/src/connectors/manifests.ts index 2d2b941f79..e86ca23d23 100644 --- a/packages/chat-platform/src/connectors/manifests.ts +++ b/packages/chat-platform/src/connectors/manifests.ts @@ -1,5 +1,6 @@ import type { ChatConnectorManifest } from "../install" import { discordManifest } from "./discord/manifest" +import { slackManifest } from "./slack/manifest" export type { ChatConnectorManifest } from "../install" @@ -9,4 +10,4 @@ export type { ChatConnectorManifest } from "../install" * so the browser bundle never pulls a connector's install flow or its HTTP * client just to draw a card. */ -export const chatConnectorManifests: ReadonlyArray = [discordManifest] +export const chatConnectorManifests: ReadonlyArray = [discordManifest, slackManifest] diff --git a/packages/chat-platform/src/connectors/slack/README.md b/packages/chat-platform/src/connectors/slack/README.md new file mode 100644 index 0000000000..c4387f2531 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/README.md @@ -0,0 +1,299 @@ +# The Slack connector + +Everything Maple knows about Slack is in this directory. Two host Workers drive it through +`ChatConnector` and neither learns which platform it is talking to: `apps/api` runs the install +half, `apps/chat-bot` runs the ingress and outbound halves. + +This is a **new Slack app**, separate from the one that delivers alerts to channels +(`slack_workspaces`, `SlackIntegrationService`) and separate from the Railway agent in +`apps/slack-agent`. It shares no table, no secret and no code with either. See +[`docs/chat-connector-cutover.md`](../../../../../docs/chat-connector-cutover.md) for running it +alongside the old agent and retiring that one. + +## What has to exist before it runs + +One Slack app, created at (from the manifest below). Its secrets are +documented here rather than in the repo's `.env.example`, which is shared ground where no platform +should be named; every name is declared once, in `api.ts`. + +| Secret | Where it comes from | How it reaches the connector | +| ---------------------------- | ------------------------------------------ | --------------------------------- | +| `MAPLE_SLACK_CLIENT_ID` | Basic Information → Client ID (not secret) | `install.requiredConfig`, by name | +| `MAPLE_SLACK_CLIENT_SECRET` | Basic Information → Client Secret | `install.requiredConfig`, by name | +| `MAPLE_SLACK_SIGNING_SECRET` | Basic Information → Signing Secret | `ingress.requiredConfig`, by name | + +The client id and secret go on **api**; the signing secret goes on **chat-bot**. A half whose names +are unset is skipped rather than fatal: the dashboard reports the connector as unavailable and +offers no connect button, the webhook route answers 503 and logs one line, and the rest of each +Worker runs. + +One more secret is needed on chat-bot, and it is not this connector's: +`MAPLE_INGEST_KEY_ENCRYPTION_KEY`, the key the per-workspace credential envelope is sealed with. It +is already bound on api. Without it on chat-bot, a Slack workspace resolves as a failed lookup and +its mentions go unanswered — see "The bot token" below. + +### The app manifest + +```yaml +display_information: + name: Maple + description: Ask Maple about your traces, errors and services, in a thread. +settings: + event_subscriptions: + request_url: https://chat.maple.dev/connectors/slack/webhook + bot_events: + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + - app_uninstalled + - tokens_revoked + interactivity: + is_enabled: true + request_url: https://chat.maple.dev/connectors/slack/webhook + org_deploy_enabled: false + socket_mode_enabled: false +features: + bot_user: + display_name: Maple +oauth_config: + redirect_urls: + - https://api.maple.dev/oauth/chat/slack/callback + scopes: + bot: + - app_mentions:read + - chat:write + - channels:history + - groups:history + - im:history + - mpim:history +``` + +**One application per production instance.** Slack allows a single Events request URL per app, and +the US and EU instances are different Workers on different hostnames — so the EU instance needs its +own Slack app with `chat.eu.maple.dev` and `api.eu.maple.dev` substituted throughout the manifest +above. They are separate applications with separate credentials, which is also what keeps an EU +workspace's events off a US Worker. A dev stage has no public hostname at all; point a scratch app +at the portless URL, or use a tunnel. + +Three things in there are load-bearing: + +- **one request URL for both surfaces.** Slack takes an Events URL and an Interactivity URL + separately; both point at the same route, and `webhook.ts` tells the two apart by content type + (interactivity arrives form-encoded, events arrive as JSON). +- **`org_deploy_enabled: false`.** An enterprise-wide install issues a token that spans every + workspace in the org and answers with `team: null`, so the row the install writes could not be + resolved from any event's `team_id`. `install.ts` refuses one by name rather than failing later. +- **the four `message.*` events.** A mention arrives as `app_mention`; a follow-up in the thread it + opened arrives only as a `message`, and only if the matching history scope is granted. + +The scopes deliberately **do not** include `users:read` — see "Who may approve" below — or +`reactions:write`: nothing reacts, the answer in the thread is the acknowledgement. + +## Trying it end to end + +1. **Put the secrets on the deployments.** Client id and secret on api, signing secret and the + encryption key on chat-bot. Both Workers bind every connector key as optional, so a stage without + them deploys and skips this connector. +2. **Save the request URL.** Slack POSTs a `url_verification` challenge the moment you save it; + `webhook.ts` answers it with the challenge, verifying the signature first like any other request. + A red "Your URL didn't respond" is almost always the signing secret, not the route. +3. **Link the workspace to a Maple organization.** In Maple: Settings → Integrations → Slack → + Connect, which runs the install flow and writes the `chat_workspaces` row (Slack's own consent + screen adds the bot at the same time). The card is behind the org's `slack_bot` rollout flag. +4. **Invite the bot to a channel** (`/invite @Maple`) and **mention it**: `@Maple why is checkout +slow?`. It answers in a thread on that message and edits one message there as the answer streams. + A reply in that thread continues the same conversation; a mention in another channel starts a + different one. + +5. **Reply in that thread without mentioning the bot.** The thread is one the bot opened, so it + answers anyway — and it reads the messages above the reply for context. In a channel it was + merely invited to, an un-addressed message is not a turn. + +One limit worth knowing before reporting a bug: a write the agent proposes renders as an approval +card that **cannot be approved yet**. + +## The bot token + +Slack mints one bot token per installed workspace, so unlike the other connector there is no +deployment-wide credential the outbound half could use. The token is what the install returns as +`ChatInstallResult.credentials` — a small JSON blob (`credentials.ts`), opaque to everything above +this directory. It is JSON rather than the bare token so a second value later is one more key here +rather than a migration and a re-install of every workspace. + +The token's **format is deliberately not validated**: the shapes it can take are Slack's to change, +and a wrong token fails the first post with Slack's own error, which reports better than a pattern +could. + +**Token rotation must stay OFF on the app.** With it on, Slack issues a short-lived `xoxe.xoxb-…` +access token plus a refresh token, and expects the app to exchange the one for the other before it +expires. This connector stores only the access token and implements no refresh, so a rotation-enabled +install would work until the first expiry and then stop posting — with nothing in the failure saying +why beyond Slack's `token_expired`. Supporting it is a real feature (a second stored value, a +refresh ahead of every use, and a write back to the row from the bot Worker, which today only reads +it), not a missing branch. Nothing validates the format, so nothing here will catch the mistake: +leaving rotation off is the operator's to get right. + +The host seals it with AES-256-GCM into `chat_workspaces.credentials_{ciphertext,iv,tag}`, with the +AAD bound to `(org_id, connector, external_workspace_id)`, and hands it back to the outbound half in +`ConnectorCredentials` under the reserved `WORKSPACE_CREDENTIALS` key. Two consequences: + +- a conversation in a workspace nobody linked has **no token**, so the transport answers "this + workspace is not connected" instead of posting as somebody else. The generic "link me first" + notice the host posts for an unlinked workspace therefore cannot be delivered on Slack — the bot + is silent until the workspace is linked, which is also what a bot with no credential can be; +- a deployment with no encryption key cannot open a stored envelope, so the lookup fails and the + mention goes unanswered with one logged error, rather than resolving to a workspace whose bot + then cannot post. + +## Install flow + +`authorizeUrl` sends the browser to `https://slack.com/oauth/v2/authorize` with the bot scopes and +the callback URL; `complete` exchanges the code at `POST https://slack.com/api/oauth.v2.access`. + +Two things are load-bearing: + +- **the team comes from the token response**, which Slack binds to the authorization code. A + callback query parameter belongs to whoever opens the callback URL; +- **Slack reports a refused exchange as HTTP 200 with `ok: false`**, so the status code is not the + answer. The whole Web API works this way, which is why `outbound.ts` reads `ok` on every call too. + +The user token is not requested at all (`user_scope` is absent): the bot is an org-level actor, and +Maple has no use for an installer identity it never links to a Maple user. + +### Settings + +None. The other connector's one setting names an approver role, and Slack's interaction payload +reports no roles — a field here would ask an admin to configure something nothing can check. + +## What ingress delivers + +| Slack | Maple | +| --------------------------------------- | -------------------------------- | +| `app_mention` | `message` (`mentionsBot: true`) | +| `message.*` with a `thread_ts` | `message` (`mentionsBot: false`) | +| `block_actions` carrying a button value | `action` | +| `app_uninstalled`, `tokens_revoked` | `workspace-removed` | + +`workspaceId` is always the envelope's `team_id` — and for a button press, the interaction's own +`team.id`, never the clicker's `user.team_id`: in a Slack Connect shared channel the clicker can +belong to a different workspace, and a different Maple org. Bot-authored messages and every `subtype` are +dropped before anything else, so two Maple deployments in one workspace cannot talk to each other. +A mention inside a subscribed channel arrives as BOTH an `app_mention` and a `message`; the +`message` copy is dropped, or one question would start a turn and then be fed into it. + +**The bot's own user id comes from the event's `authorizations` entry**, not from storage. It is per +event and per workspace, which is what lets ingress stay a pure function of the request and the +deployment's configuration. Without it the mention text is passed through with the mention still in +it, rather than a follow-up being dropped on a guess. + +### Request verification + +`X-Slack-Signature` is `v0=` plus HMAC-SHA256 over `v0:{X-Slack-Request-Timestamp}:{raw body}`, +keyed with the signing secret. `signature.ts` checks the timestamp first (five minutes either way, +Slack's documented window — a captured body stays correctly signed forever, and only its age says +otherwise), then compares in constant time. + +Every rejection answers with the **same constant message**, which the host returns as the 400 body: +naming the check that refused the request tells an attacker which one to fix. The reason goes on +the span instead (`maple.chat.reject_reason`), where it is a six-value enum an operator can break +rejections down by. Drops are annotated the same way (`maple.chat.dropped`) — an acknowledged +redelivery and a handled mention are otherwise the same 200 on the same span. + +Within the five-minute window there is **no replay protection**. A captured signed body can be +delivered again, and today that costs at most a duplicate turn. Before button presses actually +apply anything, an approval needs to be deduplicated against durable storage — the ingress cannot +do it, being a pure function the host may run in any isolate. + +**Retries are acknowledged and dropped.** Slack redelivers an event it believes was not +acknowledged, marking it with `X-Slack-Retry-Num`. This handler answers inside its own request, so a +retry means Slack did not hear the 200 — not that the event went unhandled. Deduplicating by +`event_id` instead would need storage that ingress, a pure function the host may run in any isolate, +does not have. + +### Who may approve + +**This connector declares no `identity`**, which is a policy rather than a gap: anyone who can see +the conversation may approve, and the change runs as the org-level connector identity. See +`ChatConnector.identity` for the three cases. + +Slack _can_ answer who clicked — "Sign in with Slack", its OpenID Connect flow — and implementing +it is mechanically small: the same OAuth application, the same client credentials, the same +redirect-URL list, an authorize URL at `https://slack.com/openid/connect/authorize` and two calls +(`openid.connect.token`, then `openid.connect.userInfo`). + +It is not implemented because one fact it rests on **cannot be confirmed from Slack's +documentation**: that the `sub` / `https://slack.com/user_id` claim returned by `userInfo` is the +same `U…` id that arrives as `user.id` on a `block_actions` payload. Everything about this is +plausible — Slack user ids are one namespace — but the link is keyed on that id, and the failure if +it is wrong is silent and total: `identity` present with nothing linked means every approval is +**refused**, which is worse than the weaker rule this connector ships with. Two other things are +also undocumented: whether the `openid` scopes must be declared in the app manifest, and whether +adding them forces workspaces that already installed the bot to re-authorize. + +What makes it cheap is one empirical check against a real workspace: log `sub` and the +`block_actions` `user.id` for the same person and compare them. That check belongs with the smoke +checklist in the cutover doc, and the flow can land the moment it passes. + +## Reading the conversation back + +`transport.history` is `conversations.replies` for a thread — addressed by its parent's `ts`, which +is the thread's own id — and `conversations.history` for a channel. Both take `latest` with +`inclusive: false`, which is "everything before this message", and both need the matching history +scope; without it Slack answers `not_in_channel` and the turn goes on without the context. + +The page is **sorted here rather than trusted**: `conversations.history` answers newest first and +`conversations.replies` answers oldest first, and the contract wants one order. The sort is over the +`ts` and not over the epoch-ms the contract carries — a `ts` has microsecond precision, so two +messages sent in the same second round to the same millisecond, and ordering by the rounded value +would put them in whatever order the page happened to arrive in. + +One message that will not decode is dropped on its own rather than failing the read: a page is +context, and losing the conversation around one unreadable line is the worse trade. + +## Which conversation an answer belongs to + +`transport.conversation` answers it, and performs no I/O. A Slack thread is not an object — it is +every message carrying the anchor's `ts` as its `thread_ts` — so a top-level mention's own `ts` +becomes the thread, and a mention already in one keeps that thread. The conversation key is +`channel:thread_ts`, which fits `ChatConversationKey`'s charset. `openThread` returns the anchor's +id and calls nothing. + +`opened` is **true for a top-level mention** and false everywhere else. A Slack thread begins with +the reply that first carries the parent's `ts`, so answering a top-level mention is what creates the +thread — it is a space that exists because somebody asked Maple something, which is what lets the +host treat a later un-addressed message in it as still addressed to the bot. A mention already +inside a thread, and every un-addressed follow-up, opens nothing. Neither costs a request. + +## Protocol notes worth keeping + +- **Acknowledge within three seconds.** The handler returns 200 and hands its events to the host, + which relays them afterwards. An agent turn is not a three-second operation. +- **Most failures are a 200.** `ok: false` with an `error` code. A transport reading only the status + would file every revoked token as a success. +- **A rate limit is a 429 with `Retry-After` in seconds**, and Slack spells the body code both + `ratelimited` and `rate_limited`. `chat.postMessage` is roughly one call per second per channel, + which is also why `minEditInterval` is a second — a streamed turn spends its budget on the turn + rather than on 429s. +- **There is no typing indicator for a bot token.** The method that had one belongs to the + deprecated RTM API. `typing` is a no-op rather than a wasted call. +- **mrkdwn is not markdown.** `*x*` is bold there and italic here, there are no headings and no + tables, and `&`, `<`, `>` are control characters. `mrkdwn.ts` is the whole conversion, and the + escape is also what stops an agent quoting `` out of a log from paging a company. + +`webhook.test.ts` drives the ingress from signed HTTP requests and `events.test.ts` from recorded +payload shapes. No test reaches the network. + +## What is unverified without a real Slack app + +Every fact above was checked against Slack's current documentation. These need one live install to +confirm, and the cutover doc's smoke checklist is where they get confirmed: + +- whether `app_mention` carries `thread_ts` when the bot is mentioned inside a thread (the mapping + handles both, so the consequence is only which value names the conversation); +- that `authorizations[]` names the bot user on every delivery this connector reads; +- that an un-mentioned thread reply arrives with the history scopes above and no others, and that + `conversations.replies` / `conversations.history` answer for the channel types the bot is in; +- the display name shown for an author: Slack's message events carry only a user id, so V1 uses the + id. A readable name needs `users:read`, on the same terms as the approval facts. diff --git a/packages/chat-platform/src/connectors/slack/api.ts b/packages/chat-platform/src/connectors/slack/api.ts new file mode 100644 index 0000000000..5d9ef6309a --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/api.ts @@ -0,0 +1,104 @@ +/** + * What all three halves of this connector agree on: where Slack is, what its secrets are called, + * and what the app asks a workspace for. + * + * Its own module for the same reason `./id.ts` is: the install flow, the webhook ingress and the + * outbound transport all need these, and none of them should have to import another to get them. + * + * Checked against Slack's current documentation (see `README.md` for the page behind each fact). + */ + +/** Every Web API method is a path under this base, called with an ordinary form or JSON body. */ +export const API_BASE = "https://slack.com/api" + +export const API_HOST = "slack.com" + +/** The OAuth v2 consent screen a workspace admin is sent to. Not under `/api`. */ +export const AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize" + +export const TOKEN_URL = `${API_BASE}/oauth.v2.access` + +export const POST_MESSAGE_URL = `${API_BASE}/chat.postMessage` + +export const UPDATE_MESSAGE_URL = `${API_BASE}/chat.update` + +/** What a channel holds. Answers newest first, which is the order the history contract asks for. */ +export const CHANNEL_HISTORY_URL = `${API_BASE}/conversations.history` + +/** What a thread holds. Addressed by the PARENT message's `ts`, which is the thread's own id. */ +export const THREAD_REPLIES_URL = `${API_BASE}/conversations.replies` + +/** + * The application's OAuth credentials, named in the install half's `requiredConfig`. + * + * The client id is not a secret — it rides the authorize URL the browser opens — which is why + * `ConnectorConfigKey` carries that as a flag rather than assuming it. + */ +export const CLIENT_ID_CONFIG = "MAPLE_SLACK_CLIENT_ID" + +export const CLIENT_SECRET_CONFIG = "MAPLE_SLACK_CLIENT_SECRET" + +/** + * The app's Signing Secret, named in the ingress half's `requiredConfig`. + * + * A different Worker resolves this than resolves the OAuth credentials: verifying a webhook is the + * bot's job and installing is the dashboard's, so neither holds the other's secret. Every + * deployment of this app shares one signing secret — it belongs to the Slack app, not to a + * workspace — which is what lets a pure ingress function verify a request with configuration + * alone. + */ +export const SIGNING_SECRET_CONFIG = "MAPLE_SLACK_SIGNING_SECRET" + +/** + * The bot scopes the install asks for, in the order Slack's app manifest lists them. + * + * app_mentions:read receive `app_mention` — the only event that starts a turn + * chat:write post and edit the answer + * channels:history read replies in a public channel's threads + * groups:history …in a private channel's + * im:history …in a direct message + * mpim:history …in a group direct message + * + * The four history scopes are what deliver `message` events at all, and a thread follow-up is a + * `message` event: without them the bot can be addressed once and never hears the rest of the + * conversation it started. + * + * Deliberately NOT requested: + * + * users:read the only way to learn whether a clicker administers the workspace. Slack's + * interaction payload carries no membership facts at all (see `events.ts`), so + * this scope would buy one extra API call per button press for a fact Maple does + * not yet act on. Adding it is a scope change every installed workspace has to + * re-approve, so it waits until there is something to gate. + * reactions:write nothing reacts. The bot answers in a thread, which is the acknowledgement. + */ +export const BOT_SCOPES: ReadonlyArray = [ + "app_mentions:read", + "chat:write", + "channels:history", + "groups:history", + "im:history", + "mpim:history", +] + +/** Slack's request-signing version prefix. One value has ever existed; it is `v0`. */ +export const SIGNATURE_VERSION = "v0" + +export const SIGNATURE_HEADER = "x-slack-signature" + +export const TIMESTAMP_HEADER = "x-slack-request-timestamp" + +/** + * Present when Slack is REDELIVERING an event it thinks was not acknowledged. + * + * `x-slack-retry-reason` rides with it and is not read: a retry is dropped whatever its reason. + */ +export const RETRY_HEADER = "x-slack-retry-num" + +/** + * How far a request's timestamp may sit from now, in seconds. + * + * Slack's own replay guidance: reject anything more than five minutes out. Both directions — + * a timestamp in the future is a clock that cannot be trusted to have ever been in the past. + */ +export const MAX_TIMESTAMP_SKEW_SECONDS = 300 diff --git a/packages/chat-platform/src/connectors/slack/credentials.ts b/packages/chat-platform/src/connectors/slack/credentials.ts new file mode 100644 index 0000000000..b807aa4679 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/credentials.ts @@ -0,0 +1,44 @@ +/** + * What this connector keeps per installed workspace, and how it is written down. + * + * Slack mints one bot token per workspace, so there is no deployment-wide secret the outbound half + * could use — the install returns the token and the host stores it sealed beside the row + * (`ChatInstallResult.credentials`). What crosses that boundary is this JSON, and nothing between + * the two halves reads it: to the host it is an opaque string. + * + * It is JSON rather than the bare token so that the day a second value is needed it is one more key + * here — read by the same connector that wrote it — rather than a migration of a shared table and + * a re-install of every workspace. One key is in it today. + */ +import { Option, Schema } from "effect" + +/** + * The token's FORMAT is deliberately not checked. + * + * It arrives from Slack's own token response, and the shapes it can take are Slack's to change: + * an app with token rotation enabled answers `xoxe.xoxb-…` rather than `xoxb-…`. A pattern here + * would turn a setting on Slack's side into a rejected install — and worse, into a THROWN one, + * since this is the encode side and the value would already have passed the install's own checks. + * The token is opaque to everything but the `Authorization` header; a wrong one fails the first + * post with Slack's own error, which is a better report than a regex could write. + */ +export const SlackCredentials = Schema.Struct({ + bot_token: Schema.String, +}) +export type SlackCredentials = Schema.Schema.Type + +const encode = Schema.encodeSync(Schema.fromJsonString(SlackCredentials)) +const decode = Schema.decodeUnknownOption(Schema.fromJsonString(SlackCredentials)) + +/** Write the pair down for the host to seal. Values come from Slack's own token response. */ +export const encodeSlackCredentials = (credentials: SlackCredentials): string => encode(credentials) + +/** + * Read the pair back out of `ConnectorCredentials`, or `None`. + * + * `None` covers every way this can be absent — a workspace nobody linked, a deployment whose key + * cannot open the envelope, a row written by an older encoding — and they all mean the same thing + * to the transport: there is no token, so there is nothing to post with. + */ +export const decodeSlackCredentials = (raw: string | undefined): Option.Option => + raw === undefined ? Option.none() : decode(raw) diff --git a/packages/chat-platform/src/connectors/slack/events.test.ts b/packages/chat-platform/src/connectors/slack/events.test.ts new file mode 100644 index 0000000000..8bc5ca316c --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/events.test.ts @@ -0,0 +1,274 @@ +/** + * Slack payloads → normalized events, from payloads shaped like the ones its documentation prints. + * + * The fixtures are deliberately whole envelopes rather than the fields the mapping happens to + * read: a decode that started requiring one more key would pass a hand-trimmed fixture and fail a + * real delivery. + */ +import { describe, expect, it } from "vitest" +import { blockActionsToInbound, eventCallbackToInbound, stripMention } from "./events" +import { decodeBlockActions, decodeEventRequest } from "./payloads" +import { Option } from "effect" + +const BOT = "U0LAN0Z89" + +const envelope = (event: Record, extra: Record = {}) => ({ + token: "z26uFbvR1xHJEdHE1OQiO6t8", + team_id: "T061EG9R6", + api_app_id: "A0MDYCDME", + event, + type: "event_callback", + event_id: "Ev0MDYGDKJ", + event_time: 1_515_449_522, + authorizations: [ + { + enterprise_id: null, + team_id: "T061EG9R6", + user_id: BOT, + is_bot: true, + is_enterprise_install: false, + }, + ], + is_ext_shared_channel: false, + event_context: "EC12345", + ...extra, +}) + +const mapped = (payload: Record) => { + const decoded = decodeEventRequest(JSON.stringify(payload)) + if (Option.isNone(decoded)) throw new Error("the fixture did not decode") + const request = decoded.value + if (request.type !== "event_callback") throw new Error("the fixture is not an event callback") + return eventCallbackToInbound(request) +} + +describe("slack mentions", () => { + it("turns an app_mention into a turn without the bot's own name in it", () => { + const events = mapped( + envelope({ + type: "app_mention", + user: "U061F7AUR", + text: `<@${BOT}> why is checkout slow?`, + ts: "1515449522.000016", + channel: "C0LAN2Q65", + event_ts: "1515449522000016", + }), + ) + expect(events).toEqual([ + { + type: "message", + connector: "slack", + workspaceId: "T061EG9R6", + channelId: "C0LAN2Q65", + // A top-level mention's own `ts` is the thread the answer goes in. + threadId: "1515449522.000016", + messageId: "1515449522.000016", + author: { id: "U061F7AUR", displayName: "U061F7AUR", isBot: false }, + text: "why is checkout slow?", + mentionsBot: true, + }, + ]) + }) + + it("keeps the thread a mention was already in", () => { + const events = mapped( + envelope({ + type: "app_mention", + user: "U061F7AUR", + text: `<@${BOT}> and the p99?`, + ts: "1515449600.000100", + thread_ts: "1515449522.000016", + channel: "C0LAN2Q65", + event_ts: "1515449600000100", + }), + ) + expect(events[0]).toMatchObject({ threadId: "1515449522.000016", messageId: "1515449600.000100" }) + }) + + it("reads a mention it cannot attribute to a bot user verbatim", () => { + // No `authorizations`: the text is still the question, just with the mention left in it. + const { authorizations: _dropped, ...unattributed } = envelope({ + type: "app_mention", + user: "U061F7AUR", + text: `<@${BOT}> hello`, + ts: "1515449522.000016", + channel: "C0LAN2Q65", + }) + const events = mapped(unattributed) + expect(events[0]).toMatchObject({ text: `<@${BOT}> hello`, mentionsBot: true }) + }) +}) + +describe("slack thread replies", () => { + const reply = (event: Record) => + mapped( + envelope({ + type: "message", + channel: "C0LAN2Q65", + user: "U061F7AUR", + text: "and the p99?", + ts: "1515449600.000100", + thread_ts: "1515449522.000016", + channel_type: "channel", + ...event, + }), + ) + + it("carries a reply inside a thread as an un-addressed follow-up", () => { + expect(reply({})).toEqual([ + { + type: "message", + connector: "slack", + workspaceId: "T061EG9R6", + channelId: "C0LAN2Q65", + threadId: "1515449522.000016", + messageId: "1515449600.000100", + author: { id: "U061F7AUR", displayName: "U061F7AUR", isBot: false }, + text: "and the p99?", + mentionsBot: false, + }, + ]) + }) + + it("ignores channel conversation that is not in a thread", () => { + const { thread_ts: _dropped, ...top } = { + type: "message", + channel: "C0LAN2Q65", + user: "U061F7AUR", + text: "unrelated chatter", + ts: "1515449600.000100", + thread_ts: "1515449522.000016", + channel_type: "channel", + } + expect(mapped(envelope(top))).toEqual([]) + }) + + it("ignores a reply that mentions the bot, which arrives as an app_mention too", () => { + expect(reply({ text: `<@${BOT}> and the p99?` })).toEqual([]) + }) + + it("ignores the bot's own answers and every message subtype", () => { + expect(reply({ bot_id: "B0MDYCDME" })).toEqual([]) + expect(reply({ subtype: "message_changed" })).toEqual([]) + expect(reply({ subtype: "channel_join" })).toEqual([]) + }) +}) + +describe("slack workspace removal", () => { + it("unlinks on an uninstall, naming the workspace from the envelope", () => { + expect(mapped(envelope({ type: "app_uninstalled" }))).toEqual([ + { type: "workspace-removed", connector: "slack", workspaceId: "T061EG9R6" }, + ]) + }) + + it("unlinks on revoked tokens too", () => { + expect(mapped(envelope({ type: "tokens_revoked", tokens: { bot: [BOT], oauth: [] } }))).toEqual([ + { type: "workspace-removed", connector: "slack", workspaceId: "T061EG9R6" }, + ]) + }) +}) + +describe("slack url verification", () => { + it("decodes the handshake as its own kind of request", () => { + const decoded = decodeEventRequest( + JSON.stringify({ + token: "Jhj5dZrVaK7ZwHHjRyZWjbDl", + challenge: "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + type: "url_verification", + }), + ) + expect(Option.isSome(decoded) && decoded.value.type).toBe("url_verification") + }) +}) + +describe("slack button presses", () => { + const payload = (overrides: Record = {}) => ({ + type: "block_actions", + team: { id: "T061EG9R6", domain: "maple" }, + user: { id: "U061F7AUR", username: "ada", name: "ada", team_id: "T061EG9R6" }, + api_app_id: "A0MDYCDME", + token: "9s8d9as89d8as9d8as989", + container: { type: "message", message_ts: "1515449522.000016", channel_id: "C0LAN2Q65" }, + message: { ts: "1515449522.000016", thread_ts: "1515449400.000001" }, + trigger_id: "12321423423.333649436676.d8c1bb837935619ccad0f624c448ffb3", + channel: { id: "C0LAN2Q65", name: "incidents" }, + response_url: "https://hooks.slack.com/actions/T061EG9R6/1234/abcd", + actions: [ + { + action_id: "maple_approve", + block_id: "b1", + text: { type: "plain_text", text: "Run create_dashboard" }, + value: "sess-1|call-1", + type: "button", + action_ts: "1515449530.000000", + }, + ], + ...overrides, + }) + + const mapAction = (raw: Record) => { + const decoded = decodeBlockActions(JSON.stringify(raw)) + if (Option.isNone(decoded)) throw new Error("the fixture did not decode") + return blockActionsToInbound(decoded.value) + } + + it("round-trips the token the button carried, with an actor Slack told us nothing about", () => { + expect(mapAction(payload())).toEqual([ + { + type: "action", + connector: "slack", + workspaceId: "T061EG9R6", + channelId: "C0LAN2Q65", + // The control sits on Maple's answer, which lives in the thread the question opened — + // so this is the same conversation the proposal was made in, which is what scopes + // the approval. + threadId: "1515449400.000001", + messageId: "1515449522.000016", + actionToken: "sess-1|call-1", + // An id and a name, which is all Slack sends. + actor: { id: "U061F7AUR", displayName: "ada" }, + }, + ]) + }) + + it("ignores a press that carries no value — nothing Maple rendered looks like that", () => { + expect(mapAction(payload({ actions: [{ action_id: "someone_elses", block_id: "b1" }] }))).toEqual([]) + }) + + it("falls back to the container when the payload names no channel of its own", () => { + const { channel: _dropped, ...withoutChannel } = payload() + expect(mapAction(withoutChannel)[0]).toMatchObject({ channelId: "C0LAN2Q65" }) + }) + + it("drops a press that names no team, rather than reading the clicker's own", () => { + // In a Slack Connect shared channel `user.team_id` is the clicker's OWN workspace, which + // can be a different org entirely — resolving by it would apply an approval in the wrong + // organization's name. + const { team: _dropped, ...withoutTeam } = payload() + expect(mapAction(withoutTeam)).toEqual([]) + expect( + mapAction({ ...withoutTeam, user: { id: "U1", name: "ada", team_id: "T_SOMEONE_ELSE" } }), + ).toEqual([]) + }) + + it("reads the actor's name, then their username, then their bare id", () => { + const { name: _dropped, ...withoutName } = payload().user + expect(mapAction({ ...payload(), user: withoutName })[0]).toMatchObject({ + actor: { displayName: "ada" }, + }) + expect(mapAction({ ...payload(), user: { id: "U061F7AUR" } })[0]).toMatchObject({ + actor: { displayName: "U061F7AUR" }, + }) + }) +}) + +describe("stripping the bot's mention", () => { + it("removes both of Slack's mention forms and tidies the spacing", () => { + expect(stripMention(`<@${BOT}> why is checkout slow?`, BOT)).toBe("why is checkout slow?") + expect(stripMention(`hey <@${BOT}|maple> look`, BOT)).toBe("hey look") + }) + + it("leaves everybody else's mentions alone", () => { + expect(stripMention(`<@${BOT}> ask <@U9999> about it`, BOT)).toBe("ask <@U9999> about it") + }) +}) diff --git a/packages/chat-platform/src/connectors/slack/events.ts b/packages/chat-platform/src/connectors/slack/events.ts new file mode 100644 index 0000000000..09e4f66678 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/events.ts @@ -0,0 +1,163 @@ +/** + * Slack payloads → Maple's normalized inbound events. + * + * Four kinds produce one, because four things happen downstream: a mention starts a turn, a reply + * in a thread continues it, a button click applies an approval, and an uninstall unlinks a + * workspace. Everything else Slack delivers is dropped here rather than carried further. + */ +import { Option } from "effect" +import type { InboundEvent } from "../../ingress" +import { SLACK_CONNECTOR_ID } from "./id" +import { botUserIdOf, type SlackBlockActions, type SlackEventCallback, type SlackEventBody } from "./payloads" + +/** Events that unlink the workspace. Both identify it by the envelope's `team_id` and nothing else. */ +const REMOVAL_EVENTS: ReadonlySet = new Set(["app_uninstalled", "tokens_revoked"]) + +/** + * Remove the bot's own mention from the text. + * + * Slack writes a mention as `<@U0123>`, occasionally with a display label (`<@U0123|maple>`), so + * both forms are cut. Literal splits rather than a constructed `RegExp`: the id comes off the + * wire, and a pattern built from wire data is a pattern somebody else chose. + */ +export const stripMention = (text: string, botUserId: string): string => { + const parts = text.split(`<@${botUserId}>`).join(" ").split(`<@${botUserId}|`) + return parts + .map((part, index) => (index === 0 ? part : part.slice(part.indexOf(">") + 1))) + .join(" ") + .replace(/\s+/gu, " ") + .trim() +} + +/** Whether the text addresses the bot, in either mention form. */ +const mentionsBot = (text: string, botUserId: string): boolean => + text.includes(`<@${botUserId}>`) || text.includes(`<@${botUserId}|`) + +/** + * A message this connector could answer at all. + * + * Bot-authored messages are dropped before anything else — the bot's own answers come back as + * events, and two Maple deployments in one workspace must not talk to each other. A `subtype` is + * every other thing Slack calls a message: an edit, a deletion, a join notice, a file share. None + * of them is somebody asking a question, and several carry no author at all. + */ +const isAnswerable = ( + event: SlackEventBody, +): event is SlackEventBody & { channel: string; user: string; ts: string; text: string } => + event.bot_id === undefined && + event.subtype === undefined && + event.channel !== undefined && + event.user !== undefined && + event.ts !== undefined && + event.text !== undefined + +/** + * The conversation a reply belongs to: the thread if there is one, otherwise the message itself. + * + * A top-level mention's own `ts` becomes the thread — answering it in a thread is what keeps a + * channel readable — and `outbound.ts` addresses replies with the same value. + */ +const threadOf = (event: { readonly ts: string; readonly thread_ts?: string }): string => + event.thread_ts ?? event.ts + +/** + * What an `event_callback` means to Maple, as zero or one normalized events. + * + * `app_mention` and `message` overlap: a mention inside a subscribed channel arrives as BOTH, and + * delivering it twice would start a turn and then feed the same question into it. So `message` is + * read only for replies inside a thread, and a reply that mentions the bot is left to the + * `app_mention` delivery that carries it. + */ +export const eventCallbackToInbound = (callback: SlackEventCallback): ReadonlyArray => { + const event = callback.event + if (REMOVAL_EVENTS.has(event.type)) { + return [{ type: "workspace-removed", connector: SLACK_CONNECTOR_ID, workspaceId: callback.team_id }] + } + if (event.type !== "app_mention" && !event.type.startsWith("message")) return [] + if (!isAnswerable(event)) return [] + + const botUserId = botUserIdOf(callback) + if (event.type === "app_mention") { + return [ + { + type: "message", + connector: SLACK_CONNECTOR_ID, + workspaceId: callback.team_id, + channelId: event.channel, + // Slack models a thread as a coordinate inside a channel rather than as a channel of + // its own, so both are carried and a reply needs both to land in the right place. + threadId: threadOf(event), + messageId: event.ts, + author: { id: event.user, displayName: event.user, isBot: false }, + text: Option.match(botUserId, { + onNone: () => event.text, + onSome: (id) => stripMention(event.text, id), + }), + mentionsBot: true, + }, + ] + } + + // A `message` outside a thread is channel conversation the bot was not addressed in; there is + // no follow-up for it to continue. + if (event.thread_ts === undefined) return [] + // Already delivered as `app_mention`. Checked against the bot id when Slack named one, and + // otherwise not at all: dropping a follow-up on the guess that some mention was ours would lose + // a message, where delivering one Slack also sent as a mention is a duplicate the host drops. + if (Option.isSome(botUserId) && mentionsBot(event.text, botUserId.value)) return [] + return [ + { + type: "message", + connector: SLACK_CONNECTOR_ID, + workspaceId: callback.team_id, + channelId: event.channel, + threadId: event.thread_ts, + messageId: event.ts, + author: { id: event.user, displayName: event.user, isBot: false }, + text: event.text, + mentionsBot: false, + }, + ] +} + +/** + * A button press → an `action`, if it carries one of Maple's tokens. + * + * `actor` reports what Slack sends, which is an id and a name — all the contract asks for. Whether + * that person may approve is decided by whether they linked the account to a Maple user, and this + * connector declares no `identity`, so nobody can: see the README for why, and for what the link + * would cost. + */ +export const blockActionsToInbound = (payload: SlackBlockActions): ReadonlyArray => { + // The workspace is the TEAM the interaction happened in, never `user.team_id`: in a Slack + // Connect shared channel the clicker can belong to another workspace entirely, and resolving + // by their team would answer in one org's name for another org's conversation. + const workspaceId = payload.team?.id + const channelId = payload.channel?.id ?? payload.container?.channel_id + const messageId = payload.container?.message_ts ?? payload.message?.ts + const actionToken = payload.actions.find((action) => action.value !== undefined)?.value + if (workspaceId === undefined || channelId === undefined || messageId === undefined) return [] + if (actionToken === undefined) return [] + // The control sits on Maple's own answer, which lives in the thread the question opened — so + // the answer's `thread_ts` is that thread, and naming the conversation by it is what puts the + // click on the same session as the proposal. A control somehow outside a thread falls back to + // its own message, which is what a top-level conversation would have been keyed by anyway. + const threadId = payload.message?.thread_ts ?? messageId + return [ + { + type: "action", + connector: SLACK_CONNECTOR_ID, + workspaceId, + channelId, + threadId, + messageId, + actionToken, + // An id and a name, which is all Slack sends. Whether this person may approve is not a + // fact Slack holds — it is whether they linked this account to a Maple user. + actor: { + id: payload.user.id, + displayName: payload.user.name ?? payload.user.username ?? payload.user.id, + }, + }, + ] +} diff --git a/packages/chat-platform/src/connectors/slack/id.ts b/packages/chat-platform/src/connectors/slack/id.ts new file mode 100644 index 0000000000..0362faa885 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/id.ts @@ -0,0 +1,7 @@ +import { chatConnectorId } from "../../connector" + +/** + * Its own module so each half can name the connector without importing the structure that is + * assembled from all of them. + */ +export const SLACK_CONNECTOR_ID = chatConnectorId("slack") diff --git a/packages/chat-platform/src/connectors/slack/index.ts b/packages/chat-platform/src/connectors/slack/index.ts new file mode 100644 index 0000000000..6dc42cd2a1 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/index.ts @@ -0,0 +1,24 @@ +import type { HttpClient } from "effect/unstable/http" +import type { ChatConnector } from "../../connector" +import type { ConnectorCredentials } from "../../outbound" +import { SLACK_CONNECTOR_ID } from "./id" +import { slackInstall } from "./install" +import { slackManifest } from "./manifest" +import { slackOutbound } from "./outbound" +import { slackIngress } from "./webhook" + +/** + * Slack, every half: how it is installed, how its events arrive, how a turn is carried back. + * + * Its events arrive as signed HTTP requests rather than over a socket, so `ingress` is the webhook + * kind and the host needs no connection, no Durable Object and no cron for it — one route, and the + * connector verifies its own caller. `install` is driven by a different Worker; see `README.md` in + * this directory for the one application setup all of them depend on. + */ +export const slack: ChatConnector = { + id: SLACK_CONNECTOR_ID, + manifest: slackManifest, + install: slackInstall, + outbound: slackOutbound, + ingress: slackIngress, +} diff --git a/packages/chat-platform/src/connectors/slack/install.test.ts b/packages/chat-platform/src/connectors/slack/install.test.ts new file mode 100644 index 0000000000..4b1f4ad73f --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/install.test.ts @@ -0,0 +1,243 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Layer, Option, Redacted } from "effect" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" +import { BOT_SCOPES, CLIENT_ID_CONFIG, CLIENT_SECRET_CONFIG, TOKEN_URL } from "./api" +import { decodeSlackCredentials } from "./credentials" +import { slack } from "./index" +import { slackAuthorizeUrl } from "./install" + +const config = new Map([ + [CLIENT_ID_CONFIG, Redacted.make("client-1")], + [CLIENT_SECRET_CONFIG, Redacted.make("secret-1")], +]) + +const REDIRECT_URI = "https://api.maple.test/oauth/chat/slack/callback" + +/** + * A client that dies if anything reaches it. The callback cases that use it are refused before the + * token exchange, and this is what proves it. + */ +const noNetwork = HttpClient.make(() => Effect.die("the test made a network call")) + +/** Canned token endpoint; anything else rejects. Records the form body it saw. */ +const tokenFetch = (respond: () => Response, bodies: Array = []) => + Layer.succeed(FetchHttpClient.Fetch, (( + input: Parameters[0], + init?: RequestInit, + ) => { + const url = String(input) + if (!url.startsWith(TOKEN_URL)) return Promise.reject(new Error(`unexpected fetch: ${url}`)) + // Read the body through `Request` rather than off `init`: the client is free to hand fetch a + // stream there. + return new Request(url, init).text().then((body) => { + bodies.push(body) + return respond() + }) + }) as typeof globalThis.fetch) + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) + +const completeWith = (params: URLSearchParams, fetchLayer: ReturnType) => + slack.install + .complete({ config, params, redirectUri: REDIRECT_URI }) + .pipe(Effect.provide(Layer.provide(FetchHttpClient.layer, fetchLayer))) + +/** What `oauth.v2.access` answers a single-workspace bot install with. */ +const INSTALLED = { + ok: true, + // Prefix-shaped but deliberately not token-shaped: a fixture that matches the real pattern is + // a fixture secret scanners block the push over. + access_token: "xoxb-a-workspaces-own-token", + token_type: "bot", + scope: BOT_SCOPES.join(","), + app_id: "A0KRD7HC3", + team: { id: "T9TK3CUKW", name: "Maple" }, + authed_user: { id: "U1234" }, + enterprise: null, + is_enterprise_install: false, +} + +describe("slack authorize URL", () => { + it("asks for the bot scopes and nothing a user token would need", () => { + const url = new URL(slackAuthorizeUrl("client-1", { state: "nonce", redirectUri: REDIRECT_URI })) + assert.strictEqual(url.origin + url.pathname, "https://slack.com/oauth/v2/authorize") + assert.strictEqual(url.searchParams.get("client_id"), "client-1") + assert.strictEqual(url.searchParams.get("scope"), BOT_SCOPES.join(",")) + assert.strictEqual(url.searchParams.get("redirect_uri"), REDIRECT_URI) + assert.strictEqual(url.searchParams.get("state"), "nonce") + // No user token is asked for, so Maple never holds an installer's identity. + assert.isNull(url.searchParams.get("user_scope")) + }) + + it("asks for the six scopes the connector actually uses", () => { + assert.deepStrictEqual( + [...BOT_SCOPES], + [ + "app_mentions:read", + "chat:write", + "channels:history", + "groups:history", + "im:history", + "mpim:history", + ], + ) + }) + + it.effect("reports an unconfigured deployment instead of minting a broken URL", () => + Effect.gen(function* () { + const failure = yield* slack.install + .authorizeUrl({ config: new Map(), state: "nonce", redirectUri: REDIRECT_URI }) + .pipe(Effect.flip) + assert.strictEqual(failure._tag, "@maple/chat-platform/ChatConnectorNotConfigured") + }), + ) +}) + +describe("slack install callback", () => { + it.effect("exchanges the code for the workspace and its own bot token", () => + Effect.gen(function* () { + const bodies: Array = [] + const installed = yield* completeWith( + new URLSearchParams({ code: "abc", state: "nonce" }), + tokenFetch(() => jsonResponse(INSTALLED), bodies), + ) + assert.strictEqual(installed.externalWorkspaceId, "T9TK3CUKW") + assert.strictEqual(installed.name, "Maple") + + const sent = new URLSearchParams(bodies[0] ?? "") + assert.strictEqual(sent.get("code"), "abc") + assert.strictEqual(sent.get("client_id"), "client-1") + assert.strictEqual(sent.get("client_secret"), "secret-1") + assert.strictEqual(sent.get("redirect_uri"), REDIRECT_URI) + }), + ) + + it.effect("hands the host a credential only this connector can read back", () => + Effect.gen(function* () { + const installed = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => jsonResponse(INSTALLED)), + ) + const credentials = decodeSlackCredentials(installed.credentials) + assert.deepStrictEqual( + Option.match(credentials, { onNone: () => null, onSome: (value) => value }), + { bot_token: INSTALLED.access_token }, + ) + // The stored format itself, not just the round trip: a change here is a change every + // already-installed workspace has to survive, and a round-trip test cannot see one. + assert.strictEqual(installed.credentials, `{"bot_token":"${INSTALLED.access_token}"}`) + }), + ) + + it.effect("refuses an enterprise-wide install by name rather than as a missing team", () => + Effect.gen(function* () { + // Slack answers this with `team: null` and a token that spans every workspace in the org — + // so the row this would write could not be resolved from any event's `team_id`. + const failure = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => + jsonResponse({ + ...INSTALLED, + team: null, + is_enterprise_install: true, + enterprise: { id: "E123ABC456", name: "Maple Inc" }, + }), + ), + ).pipe(Effect.flip) + assert.strictEqual(failure._tag, "@maple/chat-platform/ChatInstallFailed") + assert.include(failure.message, "enterprise-wide") + }), + ) + + it.effect("treats Slack's 200-with-ok-false as the refusal it is", () => + Effect.gen(function* () { + const failure = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => jsonResponse({ ok: false, error: "invalid_code" })), + ).pipe(Effect.flip) + assert.include(failure.message, "invalid_code") + }), + ) + + it.effect("refuses a callback Slack rejected, without calling the token endpoint", () => + Effect.gen(function* () { + const failure = yield* slack.install + .complete({ + config, + params: new URLSearchParams({ error: "access_denied" }), + redirectUri: REDIRECT_URI, + }) + .pipe(Effect.provideService(HttpClient.HttpClient, noNetwork), Effect.flip) + assert.include(failure.message, "access_denied") + }), + ) + + it.effect("refuses a callback with no code at all", () => + Effect.gen(function* () { + const failure = yield* slack.install + .complete({ config, params: new URLSearchParams(), redirectUri: REDIRECT_URI }) + .pipe(Effect.provideService(HttpClient.HttpClient, noNetwork), Effect.flip) + assert.include(failure.message, "no authorization code") + }), + ) + + it.effect("refuses a token response that names no bot token", () => + Effect.gen(function* () { + const { access_token: _dropped, ...withoutToken } = INSTALLED + const failure = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => jsonResponse(withoutToken)), + ).pipe(Effect.flip) + assert.include(failure.message, "named no workspace and bot token") + }), + ) + + it.effect("takes a token in a shape it does not recognize rather than throwing on it", () => + Effect.gen(function* () { + // An app with token rotation enabled answers `xoxe.xoxb-…`. The token's format is + // Slack's to change, and a pattern here would turn that into a failed install. + const rotating = { ...INSTALLED, access_token: "xoxe.xoxb-rotating" } + const installed = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => jsonResponse(rotating)), + ) + assert.strictEqual(installed.credentials, '{"bot_token":"xoxe.xoxb-rotating"}') + }), + ) + + it.effect("names the workspace by its id when Slack sent no name", () => + Effect.gen(function* () { + const installed = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => jsonResponse({ ...INSTALLED, team: { id: "T9TK3CUKW" } })), + ) + assert.strictEqual(installed.name, "T9TK3CUKW") + }), + ) + + it.effect("reports a non-2xx and a non-JSON token response as install failures", () => + Effect.gen(function* () { + const status = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => jsonResponse({ ok: false }, 503)), + ).pipe(Effect.flip) + assert.include(status.message, "HTTP 503") + const body = yield* completeWith( + new URLSearchParams({ code: "abc" }), + tokenFetch(() => new Response("nope", { status: 200 })), + ).pipe(Effect.flip) + assert.include(body.message, "non-JSON") + }), + ) +}) + +describe("slack settings", () => { + it.effect("accepts nothing, and says so rather than dropping a key", () => + Effect.gen(function* () { + assert.deepStrictEqual(yield* slack.install.decodeSettings({}), {}) + const failure = yield* slack.install.decodeSettings({ approver_role_id: "R1" }).pipe(Effect.flip) + assert.strictEqual(failure._tag, "@maple/chat-platform/ChatSettingsRejected") + }), + ) +}) diff --git a/packages/chat-platform/src/connectors/slack/install.ts b/packages/chat-platform/src/connectors/slack/install.ts new file mode 100644 index 0000000000..6516515ac8 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/install.ts @@ -0,0 +1,162 @@ +import { Effect } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { + ChatInstallFailed, + ChatSettingsRejected, + requireConfig, + type ChatConnectorInstall, + type ChatInstallCallback, + type ChatInstallResult, + type ChatInstallStart, + type ChatWorkspaceSettings, +} from "../../install" +import { API_HOST, AUTHORIZE_URL, BOT_SCOPES, CLIENT_ID_CONFIG, CLIENT_SECRET_CONFIG, TOKEN_URL } from "./api" +import { encodeSlackCredentials } from "./credentials" +import { SLACK_CONNECTOR_ID } from "./id" +import { decodeTokenResponse } from "./payloads" + +/** + * The consent screen. `scope` is the BOT scopes, comma separated; `user_scope` is deliberately + * absent, so Slack issues no user token and Maple never holds an installer's identity — the bot is + * an org-level actor, and a user token is a credential with nothing to spend it on. + */ +export const slackAuthorizeUrl = ( + clientId: string, + input: { state: string; redirectUri: string }, +): string => { + const params = new URLSearchParams({ + client_id: clientId, + scope: BOT_SCOPES.join(","), + redirect_uri: input.redirectUri, + state: input.state, + }) + return `${AUTHORIZE_URL}?${params.toString()}` +} + +const authorizeUrl = (input: ChatInstallStart) => + Effect.map(requireConfig(input.config, SLACK_CONNECTOR_ID, CLIENT_ID_CONFIG), (clientId) => + slackAuthorizeUrl(clientId, { state: input.state, redirectUri: input.redirectUri }), + ) + +const installFailed = (message: string) => new ChatInstallFailed({ connector: SLACK_CONNECTOR_ID, message }) + +/** + * Exchange the callback's code for the workspace it was issued against, and for the bot token that + * workspace just minted. + * + * The team comes from the TOKEN RESPONSE, which Slack binds to the authorization code, never from + * a callback query parameter — those belong to whoever opens the callback URL, and trusting one + * would let an install be pointed at a workspace the authorization never covered. + * + * An enterprise-wide install is refused by name. Slack answers it with `team: null` and an + * `enterprise` instead, and the token it issues spans every workspace in the org — so the + * `(connector, external workspace id)` row this install writes would not match the `team_id` on any + * event the bot then receives. Supporting it is a real feature (an org id as the workspace + * identity, and an `authorizations` entry to resolve each event's team), not a missing branch. + */ +const exchangeCode = Effect.fn("Slack.request", { kind: "client" })(function* (input: { + readonly clientId: string + readonly clientSecret: string + readonly code: string + readonly redirectUri: string +}) { + yield* Effect.annotateCurrentSpan({ + "peer.service": "slack", + "http.request.method": "POST", + "server.address": API_HOST, + "url.template": "/api/oauth.v2.access", + }) + const httpClient = yield* HttpClient.HttpClient + const request = HttpClientRequest.post(TOKEN_URL, { headers: { accept: "application/json" } }).pipe( + // Slack's token endpoint takes the client credentials in the form body and reads only + // `application/x-www-form-urlencoded`. The body is never annotated on this span. + HttpClientRequest.bodyUrlParams({ + client_id: input.clientId, + client_secret: input.clientSecret, + code: input.code, + redirect_uri: input.redirectUri, + }), + ) + const response = yield* httpClient + .execute(request) + .pipe(Effect.mapError((error) => installFailed(`Slack token exchange failed: ${error.message}`))) + yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }) + if (response.status < 200 || response.status >= 300) { + return yield* Effect.fail(installFailed(`Slack token exchange failed with HTTP ${response.status}`)) + } + const json = yield* response.json.pipe( + Effect.mapError(() => installFailed("Slack returned a non-JSON token response")), + ) + const decoded = yield* decodeTokenResponse(json).pipe( + Effect.mapError(() => installFailed("Slack returned an unexpected token response")), + ) + // Slack reports a refused exchange as HTTP 200 with `ok: false`, so the status said nothing. + if (!decoded.ok) { + const error = decoded.error ?? "unknown" + yield* Effect.annotateCurrentSpan({ "error.type": error }) + return yield* Effect.fail(installFailed(`Slack refused the token exchange: ${error}`)) + } + return decoded +}) + +const complete = Effect.fnUntraced(function* (input: ChatInstallCallback) { + const denied = input.params.get("error") + if (denied !== null) { + return yield* Effect.fail(installFailed(`Slack rejected the authorization: ${denied}`)) + } + const code = input.params.get("code") + if (code === null) { + return yield* Effect.fail(installFailed("Slack's callback carried no authorization code")) + } + const clientId = yield* requireConfig(input.config, SLACK_CONNECTOR_ID, CLIENT_ID_CONFIG) + const clientSecret = yield* requireConfig(input.config, SLACK_CONNECTOR_ID, CLIENT_SECRET_CONFIG) + + const decoded = yield* exchangeCode({ clientId, clientSecret, code, redirectUri: input.redirectUri }) + if (decoded.is_enterprise_install === true) { + return yield* Effect.fail( + installFailed( + "This is an enterprise-wide Slack install, which Maple cannot link yet — install the app into a single workspace instead", + ), + ) + } + const teamId = decoded.team?.id + const botToken = decoded.access_token + if (teamId === undefined || botToken === undefined) { + return yield* Effect.fail(installFailed("Slack's token response named no workspace and bot token")) + } + return { + externalWorkspaceId: teamId, + name: decoded.team?.name ?? teamId, + // The workspace's own token. The host seals it; only this connector's outbound half reads it + // back, and it is never anywhere else — not on a span, not in a log, not in an error. + credentials: encodeSlackCredentials({ bot_token: botToken }), + } satisfies ChatInstallResult +}) + +/** + * No settings. Slack's interaction payload carries no membership facts, so the approver role the + * other connector offers has nothing to check against here; an empty record is stored and a posted + * key is reported rather than silently dropped. + */ +const decodeSettings = ( + input: ChatWorkspaceSettings, +): Effect.Effect => + Object.keys(input).length === 0 + ? Effect.succeed({}) + : Effect.fail( + new ChatSettingsRejected({ + connector: SLACK_CONNECTOR_ID, + message: "Slack carries no workspace settings yet", + }), + ) + +export const slackInstall: ChatConnectorInstall = { + // The client id is public — it rides the authorize URL the browser opens. + requiredConfig: [ + { name: CLIENT_ID_CONFIG, secret: false }, + { name: CLIENT_SECRET_CONFIG, secret: true }, + ], + authorizeUrl, + complete, + decodeSettings, +} diff --git a/packages/chat-platform/src/connectors/slack/manifest.ts b/packages/chat-platform/src/connectors/slack/manifest.ts new file mode 100644 index 0000000000..1d061a9e45 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/manifest.ts @@ -0,0 +1,26 @@ +import type { ChatConnectorManifest } from "../../install" +import { SLACK_CONNECTOR_ID } from "./id" + +/** + * Pure data — imported by the dashboard through `@maple/chat-platform/manifests`, which is why + * this module must stay free of anything that pulls the connector's runtime (the install flow, its + * HTTP client, its outbound Web API calls). + * + * No settings fields. The one setting the other connector carries names an approver role, and + * Slack's interaction payload reports no roles and no administrator flag for whoever pressed a + * button — so a field here would ask an admin to configure something nothing can enforce. See this + * directory's README. + */ +export const slackManifest: ChatConnectorManifest = { + id: SLACK_CONNECTOR_ID, + name: "Slack", + description: "Add the Maple bot to a Slack workspace and link that workspace to your Maple organization.", + icon: { + viewBox: "0 0 24 24", + paths: [ + "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z", + ], + }, + accent: "#4A154B", + settingsFields: [], +} diff --git a/packages/chat-platform/src/connectors/slack/mrkdwn.test.ts b/packages/chat-platform/src/connectors/slack/mrkdwn.test.ts new file mode 100644 index 0000000000..f06241e5f3 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/mrkdwn.test.ts @@ -0,0 +1,135 @@ +/** + * The dialect conversion, which is where a turn either reads like an answer or reads like its own + * source code. + * + * The cases that matter most are the ones where the two markdowns DISAGREE — `*x*` is italic in + * one and bold in the other — and the ones where mrkdwn simply has no equivalent. + */ +import { describe, expect, it } from "vitest" +import { escapeMrkdwn, toMrkdwn } from "./mrkdwn" + +describe("escaping", () => { + it("neutralizes Slack's three control characters, ampersand first", () => { + expect(escapeMrkdwn("a & b < c > d")).toBe("a & b < c > d") + }) + + it("stops a quoted mention from paging a workspace", () => { + // An agent reading a log line back must not notify a company with it. + expect(toMrkdwn("the log said was paged")).toContain("<!channel>") + expect(toMrkdwn("<@U123> reported it")).toContain("<@U123>") + }) +}) + +describe("emphasis", () => { + it("maps markdown bold onto Slack bold", () => { + expect(toMrkdwn("**checkout** is slow")).toBe("*checkout* is slow") + expect(toMrkdwn("__checkout__ is slow")).toBe("*checkout* is slow") + }) + + it("maps markdown italic onto Slack italic rather than leaving it as bold", () => { + // The whole reason this module exists: `*x*` means different things in the two dialects. + expect(toMrkdwn("this is *probably* the cause")).toBe("this is _probably_ the cause") + }) + + it("maps strikethrough onto Slack's single tilde", () => { + expect(toMrkdwn("~~not this~~")).toBe("~not this~") + }) + + it("leaves a lone asterisk alone", () => { + expect(toMrkdwn("2 * 3 = 6")).toBe("2 * 3 = 6") + }) +}) + +describe("links", () => { + it("rewrites a markdown link into Slack's angle-bracket form", () => { + expect(toMrkdwn("see [the trace](https://app.maple.dev/traces/abc)")).toBe( + "see ", + ) + }) + + it("drops an empty label rather than leaving a dangling pipe", () => { + expect(toMrkdwn("[](https://maple.dev)")).toBe("") + }) + + it("keeps a label that contains a pipe from cutting its own text", () => { + expect(toMrkdwn("[a|b](https://maple.dev)")).toBe("") + }) + + it("treats an image the same as a link — a real chart arrives as an image block", () => { + expect(toMrkdwn("![plot](https://maple.dev/p.png)")).toBe("") + }) + + it("refuses to build a link out of anything that is not a web address", () => { + // `<…>` is broadcast syntax as well as link syntax, and a link's target is model-authored — + // so this is the one hole the document-wide escape cannot close. It has to close itself. + expect(toMrkdwn("[urgent](!channel)")).toBe("[urgent](!channel)") + expect(toMrkdwn("[hi](!here)")).toBe("[hi](!here)") + expect(toMrkdwn("[team](!subteam^S123)")).toBe("[team](!subteam^S123)") + expect(toMrkdwn("[them](@U01234ABC)")).toBe("[them](@U01234ABC)") + expect(toMrkdwn("[nope](javascript:alert(1))")).toContain("[nope]") + // A real address still becomes a link. + expect(toMrkdwn("[ok](https://maple.dev)")).toBe("") + expect(toMrkdwn("[mail](mailto:a@maple.dev)")).toBe("") + }) +}) + +describe("what mrkdwn does not have", () => { + it("renders a heading as a bold line", () => { + expect(toMrkdwn("## What I found")).toBe("*What I found*") + expect(toMrkdwn("### Deeper\ntext")).toBe("*Deeper*\ntext") + }) + + it("does not let emphasis inside a heading close the heading early", () => { + // mrkdwn has no nested bold: the inner markers would end the heading's own `*…*` and leave + // the rest of the line reading as source. + expect(toMrkdwn("## A *heading* with **bold**")).toBe("*A _heading_ with bold*") + expect(toMrkdwn("## [link](https://maple.dev) in a heading")).toBe( + "* in a heading*", + ) + }) + + it("keeps a blockquote, which mrkdwn does have", () => { + // The document-wide escape flattens every `>`; the marker at the head of a line is the one + // that has to come back. + expect(toMrkdwn("> quoted")).toBe("> quoted") + expect(toMrkdwn("> **strong** point")).toBe("> *strong* point") + expect(toMrkdwn(" > indented")).toBe(" > indented") + // Still inert anywhere else on the line, which is what stops a quoted broadcast. + expect(toMrkdwn("a > b")).toBe("a > b") + expect(toMrkdwn("the log said ")).toBe("the log said <!channel>") + }) + + it("fences a table so its columns still line up", () => { + const table = ["| service | p99 |", "| --- | --- |", "| checkout | 2.1s |", "", "after"].join("\n") + expect(toMrkdwn(table)).toBe( + ["```", "| service | p99 |", "| --- | --- |", "| checkout | 2.1s |", "```", "", "after"].join( + "\n", + ), + ) + }) + + it("leaves a row that is not a table as prose", () => { + // No delimiter line, so it is somebody writing with pipes rather than a table. + expect(toMrkdwn("| not | a | table |")).toBe("| not | a | table |") + }) + + it("turns list markers into bullets, which mrkdwn has no syntax for", () => { + expect(toMrkdwn("- one\n- two")).toBe("• one\n• two") + expect(toMrkdwn(" * nested")).toBe(" • nested") + }) +}) + +describe("code", () => { + it("keeps a fenced block verbatim and drops the language tag", () => { + const source = ["```sql", "SELECT **a** FROM t -- [x](y)", "```"].join("\n") + expect(toMrkdwn(source)).toBe(["```", "SELECT **a** FROM t -- [x](y)", "```"].join("\n")) + }) + + it("closes a block the model never closed", () => { + expect(toMrkdwn("```\nSELECT 1")).toBe("```\nSELECT 1\n```") + }) + + it("leaves an inline span alone while converting the text around it", () => { + expect(toMrkdwn("**run** `a **b** c` now")).toBe("*run* `a **b** c` now") + }) +}) diff --git a/packages/chat-platform/src/connectors/slack/mrkdwn.ts b/packages/chat-platform/src/connectors/slack/mrkdwn.ts new file mode 100644 index 0000000000..889d19cf15 Binary files /dev/null and b/packages/chat-platform/src/connectors/slack/mrkdwn.ts differ diff --git a/packages/chat-platform/src/connectors/slack/outbound.test.ts b/packages/chat-platform/src/connectors/slack/outbound.test.ts new file mode 100644 index 0000000000..d840c334c0 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/outbound.test.ts @@ -0,0 +1,501 @@ +/** + * The transport against a stubbed HTTP client — what a real Slack cannot be asked to reproduce on + * demand: a 429, a 200 that means failure, and a workspace with no stored token at all. + */ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Fiber, Layer } from "effect" +import { TestClock } from "effect/testing" +import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http" +import type { InboundMessage } from "../../ingress" +import { ConnectorCredentials, WORKSPACE_CREDENTIALS } from "../../outbound" +import { encodeSlackCredentials } from "./credentials" +import { SLACK_CONNECTOR_ID } from "./id" +import { slackOutbound } from "./outbound" + +interface Attempt { + readonly status: number + readonly body: string + readonly headers?: Record +} + +const CREDENTIALS = encodeSlackCredentials({ bot_token: "xoxb-a-workspaces-own-token" }) + +/** `null` credentials is a workspace nobody linked — deliberately not `undefined`, which a default + * parameter would fill back in. */ +const stub = (attempts: ReadonlyArray, credentials: string | null = CREDENTIALS) => { + const seen: Array = [] + const client = HttpClient.make((request) => { + const attempt = attempts[Math.min(seen.length, attempts.length - 1)] ?? { status: 200, body: "{}" } + seen.push(request) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(attempt.body, { status: attempt.status, headers: attempt.headers }), + ), + ) + }) + const config = new Map() + if (credentials !== null) config.set(WORKSPACE_CREDENTIALS, credentials) + return { + seen, + layer: Layer.mergeAll( + Layer.succeed(HttpClient.HttpClient)(client), + Layer.succeed(ConnectorCredentials)(config), + ), + } +} + +const target = { workspaceId: "T1", channelId: "C1", threadId: "1700000000.000100" } + +/** The JSON body a request carried. */ +const sentBody = (request: HttpClientRequest.HttpClientRequest): Record => { + const body = request.body + if (body._tag !== "Uint8Array") throw new Error("the request carried no JSON body") + // SAFETY: the body under test is the one the transport just wrote; a wrong one fails the + // assertion it feeds rather than escaping as a bad type. + return JSON.parse(new TextDecoder().decode(body.body)) as Record +} + +const POSTED = '{"ok":true,"channel":"C1","ts":"1700000000.000200"}' + +const mention: InboundMessage = { + type: "message", + connector: SLACK_CONNECTOR_ID, + workspaceId: "T1", + channelId: "C1", + threadId: "1700000000.000100", + messageId: "1700000000.000100", + author: { id: "U1", displayName: "U1", isBot: false }, + text: "why is checkout slow", + mentionsBot: true, +} + +describe("slack transport", () => { + it.effect("posts with the workspace's own token, in the thread it was asked in", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const ref = yield* transport.post(target, [{ kind: "prose", markdown: "hello" }]) + + expect(ref).toEqual({ target, messageId: "1700000000.000200" }) + expect(http.seen[0]?.headers["authorization"]).toBe("Bearer xoxb-a-workspaces-own-token") + expect(sentBody(http.seen[0]!)).toMatchObject({ + channel: "C1", + thread_ts: "1700000000.000100", + text: "hello", + }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("names no thread on a target that has none", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + yield* transport.post({ workspaceId: "T1", channelId: "C1" }, [ + { kind: "prose", markdown: "hello" }, + ]) + expect("thread_ts" in sentBody(http.seen[0]!)).toBe(false) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("edits by the message's own timestamp", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + yield* transport.edit({ target, messageId: "1700000000.000200" }, [ + { kind: "prose", markdown: "revised" }, + ]) + expect(http.seen[0]?.url).toContain("chat.update") + expect(sentBody(http.seen[0]!)).toMatchObject({ channel: "C1", ts: "1700000000.000200" }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("refuses to post for a workspace with no stored token, before making a request", () => { + const http = stub([{ status: 200, body: POSTED }], null) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const failure = yield* transport + .post(target, [{ kind: "prose", markdown: "hello" }]) + .pipe(Effect.flip) + expect(failure.message).toContain("not connected to Maple") + expect(http.seen).toHaveLength(0) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("waits out a rate limit Slack reported with a 200, in either spelling", () => { + const http = stub([ + { status: 200, body: '{"ok":false,"error":"rate_limited"}' }, + { status: 200, body: '{"ok":false,"error":"ratelimited"}' }, + { status: 200, body: POSTED }, + ]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const running = yield* Effect.forkChild( + transport.post(target, [{ kind: "prose", markdown: "x" }]), + ) + // No `Retry-After` on a 200, so each attempt waits the default second. + yield* TestClock.adjust("2 seconds") + expect(yield* Fiber.join(running)).toMatchObject({ messageId: "1700000000.000200" }) + expect(http.seen).toHaveLength(3) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("clamps a rate limit that asks for longer than a turn can wait", () => { + const http = stub([ + { status: 429, body: "{}", headers: { "retry-after": "600" } }, + { status: 200, body: POSTED }, + ]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const running = yield* Effect.forkChild( + transport.post(target, [{ kind: "prose", markdown: "x" }]), + ) + // Ten minutes asked for, thirty seconds honoured. + yield* TestClock.adjust("30 seconds") + expect(yield* Fiber.join(running)).toMatchObject({ messageId: "1700000000.000200" }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("falls back to a default wait when Retry-After is not a number", () => { + const http = stub([ + { status: 429, body: "{}", headers: { "retry-after": "soon" } }, + { status: 200, body: POSTED }, + ]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const running = yield* Effect.forkChild( + transport.post(target, [{ kind: "prose", markdown: "x" }]), + ) + yield* TestClock.adjust("1 second") + expect(yield* Fiber.join(running)).toMatchObject({ messageId: "1700000000.000200" }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reports an HTTP failure and an unreadable reply as this operation's failure", () => { + const http = stub([{ status: 500, body: "upstream is unwell" }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const failure = yield* transport + .post(target, [{ kind: "prose", markdown: "x" }]) + .pipe(Effect.flip) + expect(failure.status).toBe(500) + expect(failure.operation).toBe("post") + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("fails the post when Slack answers ok with no timestamp to edit later", () => { + const http = stub([{ status: 200, body: '{"ok":true,"channel":"C1"}' }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const failure = yield* transport + .post(target, [{ kind: "prose", markdown: "x" }]) + .pipe(Effect.flip) + expect(failure.message).toContain("no message timestamp") + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reads Slack's 200-with-ok-false as the failure it is", () => { + const http = stub([{ status: 200, body: '{"ok":false,"error":"channel_not_found"}' }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const failure = yield* transport + .post(target, [{ kind: "prose", markdown: "hello" }]) + .pipe(Effect.flip) + expect(failure.message).toContain("channel_not_found") + expect(failure.operation).toBe("post") + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("waits out a 429 for as long as Slack asked, then posts", () => { + const http = stub([ + { status: 429, body: '{"ok":false,"error":"ratelimited"}', headers: { "retry-after": "3" } }, + { status: 200, body: POSTED }, + ]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const running = yield* Effect.forkChild( + transport.post(target, [{ kind: "prose", markdown: "x" }]), + ) + yield* TestClock.adjust("2 seconds") + expect(http.seen).toHaveLength(1) + yield* TestClock.adjust("1 second") + expect(yield* Fiber.join(running)).toMatchObject({ messageId: "1700000000.000200" }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("gives up rather than waiting out a rate limit forever", () => { + const http = stub([{ status: 429, body: "{}", headers: { "retry-after": "1" } }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const running = yield* Effect.forkChild( + transport.post(target, [{ kind: "prose", markdown: "x" }]).pipe(Effect.flip), + ) + yield* TestClock.adjust("1 minute") + const failure = yield* Fiber.join(running) + expect(failure.status).toBe(429) + expect(http.seen).toHaveLength(3) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("shows nothing for typing, because Slack has nothing to show", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + yield* transport.typing(target) + expect(http.seen).toHaveLength(0) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("opens a thread without asking Slack — a thread is just replies to a message", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const threadId = yield* transport.openThread({ + workspaceId: "T1", + channelId: "C1", + anchorMessageId: "1700000000.000100", + title: "why is checkout slow", + }) + expect(threadId).toBe("1700000000.000100") + expect(http.seen).toHaveLength(0) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reads a thread's replies, newest first, bounded by the message asked about", () => { + const http = stub([ + { + status: 200, + // Slack answers a thread OLDEST first. The contract wants the other order, and the + // bound has to cut the oldest — so the page is sorted here rather than trusted. + body: JSON.stringify({ + ok: true, + messages: [ + { ts: "1700000000.000100", user: "U1", text: "first" }, + { ts: "1700000000.000200", bot_id: "B1", username: "Maple", text: "answer" }, + { ts: "1700000000.000300", user: "U2", text: "third" }, + ], + }), + }, + ]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const history = yield* transport.history(target, { limit: 10, before: "1700000000.000400" }) + + // All three fall in the same SECOND, so they order by the `ts` and not by the epoch-ms + // the contract carries — which is the whole reason the sort does not use `at`. + expect(history).toEqual([ + { displayName: "U2", isBot: false, text: "third", at: 1_700_000_000_000 }, + // Maple's own answers come back too, and the model is told which lines are its. + { displayName: "Maple", isBot: true, text: "answer", at: 1_700_000_000_000 }, + { displayName: "U1", isBot: false, text: "first", at: 1_700_000_000_000 }, + ]) + expect(http.seen[0]?.url).toContain("conversations.replies") + expect(sentBody(http.seen[0]!)).toEqual({ + channel: "C1", + // A full page, not the caller's 10 — see the thread-paging case below. + limit: 200, + latest: "1700000000.000400", + inclusive: false, + // The thread is addressed by its parent's `ts`. + ts: "1700000000.000100", + }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reads a channel when the conversation is not a thread", () => { + const http = stub([{ status: 200, body: '{"ok":true,"messages":[]}' }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + yield* transport.history( + { workspaceId: "T1", channelId: "C1" }, + { + limit: 5, + before: "1700000000.000400", + }, + ) + expect(http.seen[0]?.url).toContain("conversations.history") + expect("ts" in sentBody(http.seen[0]!)).toBe(false) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("holds the request to Slack's own ceiling however much was asked for", () => { + const http = stub([{ status: 200, body: '{"ok":true,"messages":[]}' }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + yield* transport.history(target, { limit: 100_000, before: "1700000000.000400" }) + expect(sentBody(http.seen[0]!)).toMatchObject({ limit: 200 }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("asks a thread for a full page, because it answers from the oldest end", () => { + const http = stub([ + { + status: 200, + body: JSON.stringify({ + ok: true, + // Oldest first, which is how `conversations.replies` answers. Asking it for two + // would have returned these two and called them the newest in the thread. + messages: [ + { ts: "1700000000.000100", user: "U1", text: "oldest" }, + { ts: "1700000001.000100", user: "U2", text: "older" }, + { ts: "1700000002.000100", user: "U3", text: "newer" }, + { ts: "1700000003.000100", user: "U4", text: "newest" }, + ], + }), + }, + ]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const history = yield* transport.history(target, { limit: 2, before: "1700000004.000000" }) + + // The bound still cuts to two — at the right end. + expect(history.map((message) => message.text)).toEqual(["newest", "newer"]) + expect(sentBody(http.seen[0]!)).toMatchObject({ limit: 200 }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("asks a channel only for what was wanted, since it answers newest first", () => { + const http = stub([{ status: 200, body: '{"ok":true,"messages":[]}' }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + yield* transport.history( + { workspaceId: "T1", channelId: "C1" }, + { + limit: 5, + before: "1700000000.000400", + }, + ) + expect(sentBody(http.seen[0]!)).toMatchObject({ limit: 5 }) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("gives up on a rate-limited history read instead of delaying the turn", () => { + const http = stub([{ status: 429, body: "{}", headers: { "retry-after": "30" } }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + // No `TestClock.adjust`: if this retried it would never finish here, which is the point — + // the context is not worth making somebody wait half a minute for their answer. + const failure = yield* transport + .history(target, { limit: 10, before: "1700000000.000400" }) + .pipe(Effect.flip) + expect(failure.operation).toBe("history") + expect(http.seen).toHaveLength(1) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("loses one unreadable message rather than the conversation around it", () => { + const http = stub([ + { + status: 200, + body: JSON.stringify({ + ok: true, + messages: [ + { ts: "1700000000.000100", user: "U1", text: "kept" }, + // No `ts` at all, and a `ts` that is not one: neither can be dated. + { user: "U2", text: "no timestamp" }, + { ts: "not-a-timestamp", user: "U3", text: "bad timestamp" }, + // No text is still a message; the host leaves it out of the context. + { ts: "1700000000.000400", user: "U4" }, + ], + }), + }, + ]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const history = yield* transport.history(target, { limit: 10, before: "1700000000.000500" }) + expect(history.map((message) => message.text)).toEqual(["", "kept"]) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reports a refused history read as this operation's failure", () => { + const http = stub([{ status: 200, body: '{"ok":false,"error":"not_in_channel"}' }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const failure = yield* transport + .history(target, { limit: 10, before: "1700000000.000400" }) + .pipe(Effect.flip) + expect(failure.operation).toBe("history") + expect(failure.message).toContain("not_in_channel") + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("names a conversation by channel and thread, and makes no call to do it", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const conversation = yield* transport.conversation(mention) + expect(conversation).toEqual({ + conversationKey: "C1:1700000000.000100", + target: { workspaceId: "T1", channelId: "C1", threadId: "1700000000.000100" }, + opened: true, + }) + expect(http.seen).toHaveLength(0) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("answers a top-level mention in a thread of its own message", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const { threadId: _dropped, ...topLevel } = mention + const conversation = yield* transport.conversation(topLevel) + // The mention's own `ts` becomes the thread, so the answer sits under the question. + expect(conversation.target.threadId).toBe("1700000000.000100") + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("names a click's conversation by the thread its control sits in", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + // The same key the mention that opened this thread produced — that is what scopes the + // approval to the proposal it answers, rather than to the bot's own answer message. + const conversation = yield* transport.conversation({ + type: "action", + connector: SLACK_CONNECTOR_ID, + workspaceId: "T1", + channelId: "C1", + threadId: "1700000000.000100", + messageId: "1700000000.000900", + actionToken: "sess-1|call-1", + actor: { id: "U1", displayName: "ada" }, + }) + + expect(conversation.conversationKey).toBe("C1:1700000000.000100") + // A click opens nothing — it happened in a conversation that already existed. + expect(conversation.opened).toBe(false) + expect(http.seen).toHaveLength(0) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reports a top-level mention as a conversation the bot opened", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + // A Slack thread begins with the first reply carrying the parent's `ts`, so answering a + // top-level mention is what creates it — and ingress names the thread by that same `ts`. + const topLevel = { ...mention, messageId: "1700000000.000100" } + expect((yield* transport.conversation(topLevel)).opened).toBe(true) + expect(http.seen).toHaveLength(0) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("reports a mention inside an existing thread as one it did not open", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + const inThread = { ...mention, messageId: "1700000000.000900" } + expect((yield* transport.conversation(inThread)).opened).toBe(false) + }).pipe(Effect.provide(http.layer)) + }) + + it.effect("opens nothing for a message that addressed nobody", () => { + const http = stub([{ status: 200, body: POSTED }]) + return Effect.gen(function* () { + const transport = yield* slackOutbound.transport + // An unaddressed follow-up can only ever continue a conversation that already exists. + const followUp = { ...mention, mentionsBot: false, messageId: "1700000000.000100" } + expect((yield* transport.conversation(followUp)).opened).toBe(false) + }).pipe(Effect.provide(http.layer)) + }) +}) diff --git a/packages/chat-platform/src/connectors/slack/outbound.ts b/packages/chat-platform/src/connectors/slack/outbound.ts new file mode 100644 index 0000000000..306b15ec3b --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/outbound.ts @@ -0,0 +1,434 @@ +/** + * Slack's outbound half: the Web API over Effect's `HttpClient`. + * + * The bot token is per WORKSPACE, not per deployment — Slack mints one at install — so it arrives + * inside the credentials the host sealed against this conversation's row and handed back under + * `WORKSPACE_CREDENTIALS`. A conversation whose workspace nothing has linked therefore has no + * token at all, and every call answers that rather than posting as somebody else. + * + * Every attempt is a `Client`-kind span carrying `peer.service` and the method's own path, the + * same treatment alert delivery gives its providers, so the dependency is visible on the service + * map. The path is a Web API METHOD name, which is already low-cardinality — Slack addresses a + * channel in the body, not in the URL. + * + * Checked against Slack's Web API documentation: `chat.postMessage` takes `channel`, `text`, + * `blocks` and `thread_ts`, `chat.update` takes `channel`, `ts`, `text` and `blocks`, most failures + * arrive as HTTP 200 with `ok: false`, and a rate limit is a 429 with `Retry-After` in whole + * seconds. There is no typing indicator for a bot token on the Web API at all — the RTM method + * that had one is deprecated and not available here. + */ +import { ChatConversationKey } from "@maple/primitives" +import { Array as Arr, Duration, Effect, Option, Order, Redacted, Schema } from "effect" +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import type { ConnectorConfig, InboundAction, InboundMessage } from "../../ingress" +import { + ChatOutboundError, + ConnectorCredentials, + WORKSPACE_CREDENTIALS, + type ChatConversation, + type ChatHistoryMessage, + type ChatMessageRef, + type ChatOutbound, + type ChatOutboundOperation, + type ChatTarget, + type ChatThreadRequest, +} from "../../outbound" +import type { ChatBlock } from "../../render/blocks" +import { + API_HOST, + CHANNEL_HISTORY_URL, + POST_MESSAGE_URL, + THREAD_REPLIES_URL, + UPDATE_MESSAGE_URL, +} from "./api" +import { decodeSlackCredentials } from "./credentials" +import { SLACK_CONNECTOR_ID } from "./id" +import { decodeApiResult, decodeHistoryMessage } from "./payloads" +import { renderSlackMessage, type SlackApiRequest, type SlackMessageRequest } from "./render" + +/** + * The budget the neutral cutting is held to. + * + * Slack's own ceiling is 40000 characters of `text`, and a section block's is 3000 — but the real + * limit is what somebody reads in a channel between other people's messages, so a turn is cut to + * one section's worth and continues in the next message. + */ +const MAX_MESSAGE_CHARS = 3000 + +/** + * Slack's `chat.*` tier is roughly one call per second per channel, bursts tolerated but not + * promised. A streamed turn is edited at that rate rather than faster, so the budget goes on the + * turn instead of on 429s. + */ +const MIN_EDIT_INTERVAL = Duration.seconds(1) + +/** How many times a 429 is waited out before the turn gives up on the message. */ +const MAX_RATE_LIMIT_ATTEMPTS = 3 + +/** + * The longest a 429 may park a turn. + * + * `Retry-After` is a remote number reaching `Effect.sleep`: a workspace-wide limit can legitimately + * ask for minutes, and a wrong value would pin the fiber for as long as it says. Waiting the + * ceiling and trying again costs one attempt; obeying an unbounded value costs the turn. + */ +const MAX_RETRY_AFTER = Duration.seconds(30) + +const DEFAULT_RETRY_AFTER = Duration.seconds(1) + +/** Past an hour the value is not a rate limit, it is a bad number; fall through to the default. */ +const MAX_RETRY_AFTER_SECONDS = 3600 + +/** + * Slack's own ceiling is far higher (999 for a channel page, 1000 for a thread), and its + * documentation recommends staying well under it. A conversation's context is bounded by what a + * model can read anyway, so the request is bounded here rather than by the caller's optimism. + */ +const MAX_HISTORY_LIMIT = 200 + +/** + * A `ts` is SECONDS with microsecond precision — `"1700000000.000100"`. Not `Number.parseFloat`: + * that reads `"12abc"` as `12`, and a message dated from a value Slack did not send is worse than + * one left out. + */ +const historySeconds = (ts: string): Option.Option => + /^\d+\.\d+$/.test(ts) ? Option.some(Number(ts)) : Option.none() + +/** + * One earlier message, with the full-precision instant kept beside it. + * + * The contract carries epoch MILLISECONDS, and Slack's `ts` is finer than that — two messages sent + * in the same second round to the same millisecond, and ordering them by the rounded value puts a + * conversation in whatever order the page happened to arrive in. So the ordering uses the `ts` and + * only the contract's field is rounded. + * + * Decoded per message rather than per page, deliberately: a page is CONTEXT, and one message this + * connector cannot read — a subtype Slack added, a field that started arriving `null` — is not + * worth losing the conversation around it for. + * + * `username` before `user` because a bot-posted message carries the first and not always the + * second; the bare id is the last resort, and it is what an un-resolved author reads as throughout + * this connector (see the README: a readable name needs a scope this app does not request). + */ +interface HistoryEntry { + readonly seconds: number + readonly message: ChatHistoryMessage +} + +const historyEntry = (raw: unknown): Option.Option => + Option.flatMap(decodeHistoryMessage(raw), (message) => + Option.map(historySeconds(message.ts), (seconds) => ({ + seconds, + message: { + displayName: message.username ?? message.user ?? "unknown", + // Maple's own answers come back here too; the model is told which lines are its. + isBot: message.bot_id !== undefined || message.subtype === "bot_message", + text: message.text ?? "", + at: Math.round(seconds * 1000), + }, + })), + ) + +/** Slack's two spellings for the same thing, both of which it documents. */ +const RATE_LIMIT_ERRORS: ReadonlySet = new Set(["ratelimited", "rate_limited"]) + +const decodeHeaderRetryAfter = Schema.decodeUnknownOption( + Schema.FiniteFromString.pipe( + Schema.check(Schema.isBetween({ minimum: 0, maximum: MAX_RETRY_AFTER_SECONDS })), + ), +) + +/** + * A channel and the thread inside it, joined into one key. + * + * Slack models a thread as a coordinate within a channel rather than as a channel of its own, so + * it takes both to name a conversation. A `ts` is `1700000000.000100` — digits and a dot, which + * the key's charset carries, and the `:` between them is in it too. + */ +const conversationKeyOf = (channelId: string, threadId: string): string => `${channelId}:${threadId}` + +/** + * Every id Slack mints fits the key's charset — but these came off the wire, so they are decoded + * rather than branded, and one that is not fails the turn instead of escaping as a defect. + */ +const decodeConversationKey = Schema.decodeUnknownOption(ChatConversationKey) + +/** Internal to the retry loop: a rate limit and how long it asked for. Never leaves this module. */ +class SlackRateLimited extends Schema.TaggedError()( + "@maple/chat-platform/connectors/slack/RateLimited", + { wait: Schema.Duration }, +) {} + +export const slackOutbound: ChatOutbound = { + connectorId: SLACK_CONNECTOR_ID, + limits: { maxMessageChars: MAX_MESSAGE_CHARS, minEditInterval: MIN_EDIT_INTERVAL }, + transport: Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const config: ConnectorConfig = yield* ConnectorCredentials + const credentials = decodeSlackCredentials(config.get(WORKSPACE_CREDENTIALS)) + const token = Option.map(credentials, (value) => Redacted.make(value.bot_token)) + + /** + * One Web API call, as one client span. + * + * A rate limit fails with {@link SlackRateLimited} rather than being retried in here, so the + * attempts it costs are sibling spans that each record their own status instead of nesting + * three deep under the first one. + */ + const attempt = Effect.fn("Slack.request", { kind: "client" })(function* ( + bearer: Redacted.Redacted, + operation: ChatOutboundOperation, + method: string, + url: string, + payload: SlackApiRequest, + ) { + yield* Effect.annotateCurrentSpan({ + "peer.service": "slack", + "http.request.method": "POST", + "server.address": API_HOST, + "url.template": `/api/${method}`, + }) + const response = yield* client + .execute( + HttpClientRequest.post(url, { + headers: { + authorization: `Bearer ${Redacted.value(bearer)}`, + "content-type": "application/json; charset=utf-8", + }, + }).pipe(HttpClientRequest.bodyJsonUnsafe(payload)), + ) + .pipe( + // The reason matters: an encode or invalid-url failure is Maple's own bug, and + // collapsing it into "Slack was unreachable" files it forever as Slack's. + Effect.mapError((cause) => + failed(operation, `Slack request failed (${cause.reason._tag})`, { cause }), + ), + ) + yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }) + if (response.status === 429) return yield* new SlackRateLimited({ wait: retryAfter(response) }) + if (response.status >= 300) { + return yield* failed(operation, `Slack answered ${response.status}`, { + status: response.status, + }) + } + const json = yield* response.json.pipe( + Effect.mapError((cause) => failed(operation, "Slack's reply could not be read", { cause })), + ) + const result = yield* decodeApiResult(json).pipe( + Effect.mapError((cause) => + failed(operation, "Slack answered something unreadable", { cause }), + ), + ) + // Slack reports most failures with a 200, so the status told us nothing on its own. + if (!result.ok) { + const error = result.error ?? "unknown" + yield* Effect.annotateCurrentSpan({ "error.type": error }) + if (RATE_LIMIT_ERRORS.has(error)) { + return yield* new SlackRateLimited({ wait: retryAfter(response) }) + } + return yield* failed(operation, `Slack refused the call: ${error}`, { + status: response.status, + }) + } + return result + }) + + const send = ( + operation: ChatOutboundOperation, + method: string, + url: string, + payload: SlackApiRequest, + ): Effect.Effect< + { readonly ts?: string | undefined; readonly messages?: ReadonlyArray | undefined }, + ChatOutboundError + > => { + // Checked BEFORE the client span is opened. The workspace having no stored token is a + // Maple-side state — nobody linked it, or the envelope could not be opened — and + // recording it inside a `Slack.request` span would put a failed edge on the service map + // for a call that was never made. + if (Option.isNone(token)) { + return Effect.fail(failed(operation, "This Slack workspace is not connected to Maple")) + } + const bearer = token.value + const tryOnce = ( + count: number, + ): Effect.Effect< + { + readonly ts?: string | undefined + readonly messages?: ReadonlyArray | undefined + }, + ChatOutboundError + > => + attempt(bearer, operation, method, url, payload).pipe( + Effect.catchTag("@maple/chat-platform/connectors/slack/RateLimited", (limited) => + // A read of the conversation is CONTEXT, and the turn has not started yet — + // waiting a rate limit out here delays the answer to buy background the model + // can do without. An answer is worth waiting for; the history behind it is not. + count >= MAX_RATE_LIMIT_ATTEMPTS || operation === "history" + ? Effect.fail( + failed(operation, "Slack kept rate limiting this message", { + status: 429, + }), + ) + : Effect.sleep(limited.wait).pipe(Effect.andThen(tryOnce(count + 1))), + ), + ) + return tryOnce(1) + } + + /** `thread_ts` is left out entirely when there is no thread; Slack rejects an empty one. */ + const body = ( + channelId: string, + blocks: ReadonlyArray, + threadId?: string, + ): SlackMessageRequest => ({ + channel: channelId, + ...renderSlackMessage(blocks), + ...(threadId === undefined ? undefined : { thread_ts: threadId }), + }) + + return { + post: Effect.fn("Slack.post")(function* (target: ChatTarget, blocks: ReadonlyArray) { + const result = yield* send( + "post", + "chat.postMessage", + POST_MESSAGE_URL, + body(target.channelId, blocks, target.threadId), + ) + if (result.ts === undefined) { + return yield* failed("post", "Slack answered with no message timestamp") + } + return { target, messageId: result.ts } satisfies ChatMessageRef + }), + + edit: (ref: ChatMessageRef, blocks: ReadonlyArray) => + // `chat.update` addresses the message by its own `ts`; a thread is not named again. + send("edit", "chat.update", UPDATE_MESSAGE_URL, { + ...body(ref.target.channelId, blocks), + ts: ref.messageId, + }).pipe(Effect.asVoid), + + /** + * What was said in this conversation before a message, newest first. + * + * Two methods, one shape: a thread is `conversations.replies` addressed by its parent's + * `ts`, a channel is `conversations.history`, and both take the same bound. `latest` with + * `inclusive: false` is "everything before this message", which is what the contract asks + * for. + * + * The page is sorted here rather than trusted: `conversations.history` answers newest + * first and `conversations.replies` answers oldest first, and the contract wants one + * order. Sorting what came back costs nothing at these sizes and cannot be got wrong by a + * method Slack changes the default of. + * + * Sorting cannot recover what was never sent, though, and that is why a THREAD is asked + * for a full page rather than for `limit`: it answers from the OLDEST end, so a thread + * with more replies than the bound would otherwise hand back the start of the + * conversation and call it the newest. Asking for the ceiling and cutting here reads the + * right end of any thread up to that many replies — past which the oldest are dropped, + * which is the bound doing its job rather than the paging failing. A channel needs none + * of this: it answers from the newest end already. + */ + history: Effect.fn("Slack.history")(function* ( + target: ChatTarget, + options: { readonly limit: number; readonly before: string }, + ) { + const limit = Math.min(options.limit, MAX_HISTORY_LIMIT) + const thread = target.threadId + const result = yield* send( + "history", + thread === undefined ? "conversations.history" : "conversations.replies", + thread === undefined ? CHANNEL_HISTORY_URL : THREAD_REPLIES_URL, + { + channel: target.channelId, + limit: thread === undefined ? limit : MAX_HISTORY_LIMIT, + latest: options.before, + inclusive: false, + ...(thread === undefined ? undefined : { ts: thread }), + }, + ) + const page = Arr.getSomes(Arr.map(result.messages ?? [], historyEntry)) + return Arr.map(Arr.take(Arr.sort(page, newestFirst), limit), (entry) => entry.message) + }), + + /** + * Slack has no typing indicator a bot token can set. Rather than spend a call on + * something that shows nothing, the driver's first posted message is the acknowledgement. + */ + typing: () => Effect.void, + + /** + * A Slack thread is not an object — it is every message that carries the anchor's `ts` as + * its `thread_ts` — so opening one is nothing to do and the anchor's own id is its + * address. + */ + openThread: (request: ChatThreadRequest) => Effect.succeed(request.anchorMessageId), + + /** + * A mention is answered in its own thread, so a channel keeps reading as a channel. + * + * A top-level mention's `ts` becomes the thread, and a mention already inside one keeps + * that thread — which is what `ingress` put in `threadId`, so this performs no I/O. A + * follow-up in the same thread arrives with the same `thread_ts` and lands on the same + * session without anything being remembered between events. + * + * A CLICK opens nothing: it happened inside a conversation that already exists, and the + * name this answers with is what scopes the approval. Ingress reads it from the thread + * the control's own message sits in, so it is the platform's address for where the click + * landed rather than anything reconstructed here. + */ + conversation: (message: InboundMessage | InboundAction) => { + const threadId = message.threadId ?? message.messageId + // A Slack thread begins with the reply that first carries the parent's `ts`, so a + // top-level mention is a conversation THIS bot is about to open — and one the host + // may then answer unaddressed messages in. A mention already inside a thread, and + // every un-addressed follow-up, opens nothing. No request either way: the thread's + // address is the anchor's own id. + const opened = + message.type === "message" && message.mentionsBot && threadId === message.messageId + return Option.match(decodeConversationKey(conversationKeyOf(message.channelId, threadId)), { + onNone: () => + Effect.fail(failed("thread", "Slack named a conversation Maple cannot address")), + onSome: (conversationKey): Effect.Effect => + Effect.succeed({ + conversationKey, + target: { + workspaceId: message.workspaceId, + channelId: message.channelId, + threadId, + }, + opened, + }), + }) + }, + } + }), +} + +/** + * Newest first, the order the contract carries and the order a bound has to cut in. + * + * Over the `ts` rather than the contract's rounded `at`, so two messages in the same second still + * order against each other. + */ +const newestFirst = Order.mapInput(Order.flip(Order.Number), (entry: HistoryEntry) => entry.seconds) + +const failed = ( + operation: ChatOutboundOperation, + message: string, + extra: { readonly status?: number; readonly cause?: unknown } = {}, +) => new ChatOutboundError({ message, connectorId: SLACK_CONNECTOR_ID, operation, ...extra }) + +/** + * How long Slack asked us to wait, clamped. + * + * `Retry-After` is the documented source and the only one: unlike some APIs, Slack's rate-limit + * body carries no duration of its own, so a 200-with-`ratelimited` falls back to the default. + */ +const retryAfter = (response: HttpClientResponse.HttpClientResponse): Duration.Duration => { + const seconds = decodeHeaderRetryAfter(response.headers["retry-after"]) + return Duration.min( + MAX_RETRY_AFTER, + Option.isSome(seconds) ? Duration.seconds(seconds.value) : DEFAULT_RETRY_AFTER, + ) +} diff --git a/packages/chat-platform/src/connectors/slack/payloads.ts b/packages/chat-platform/src/connectors/slack/payloads.ts new file mode 100644 index 0000000000..878a4532d6 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/payloads.ts @@ -0,0 +1,176 @@ +/** + * Slack's wire vocabulary, as far as this connector reads it. + * + * Every schema is deliberately partial: Slack's payloads are large, most of each one is + * irrelevant, and Effect Schema drops the keys a struct does not name — so a field added upstream + * cannot break decoding here. + * + * Verified against Slack's Events API, event reference and interaction-payload documentation; the + * pages behind each are listed in this directory's README. + */ +import { Option, Schema } from "effect" + +/** Sent once, when a request URL is first saved in the app's configuration. */ +export const UrlVerification = Schema.Struct({ + type: Schema.Literal("url_verification"), + challenge: Schema.String, +}) + +/** + * Who Slack says this event was delivered on behalf of. + * + * This is where the bot's own user id comes from. It is per event and per workspace, so the + * connector never has to store it or be told it — which matters, because ingress is a pure + * function over the request and the deployment's configuration, and the bot user id is neither. + */ +const Authorization = Schema.Struct({ + user_id: Schema.optionalKey(Schema.String), + is_bot: Schema.optionalKey(Schema.Boolean), +}) + +/** The `app_mention` and `message.*` fields this connector reads. */ +const EventBody = Schema.Struct({ + type: Schema.String, + /** Absent on the subtypes this connector drops; a message with no channel is not answerable. */ + channel: Schema.optionalKey(Schema.String), + user: Schema.optionalKey(Schema.String), + text: Schema.optionalKey(Schema.String), + ts: Schema.optionalKey(Schema.String), + /** Present when the message is a reply; the parent's `ts`, which names the thread. */ + thread_ts: Schema.optionalKey(Schema.String), + /** `message_changed`, `channel_join`, `bot_message`, … — anything but a plain message. */ + subtype: Schema.optionalKey(Schema.String), + /** Set on a message a bot posted, including this bot's own answers. */ + bot_id: Schema.optionalKey(Schema.String), +}) +export type SlackEventBody = Schema.Schema.Type + +/** The envelope every delivered event arrives in. */ +export const EventCallback = Schema.Struct({ + type: Schema.Literal("event_callback"), + team_id: Schema.String, + event_id: Schema.optionalKey(Schema.String), + event: EventBody, + authorizations: Schema.optionalKey(Schema.Array(Authorization)), +}) +export type SlackEventCallback = Schema.Schema.Type + +/** Either of the two things a POST to the events URL can be. Anything else decodes to neither. */ +export const EventRequest = Schema.Union([UrlVerification, EventCallback]) + +/** + * Both decoders take the RAW JSON text, not a parsed object. + * + * `JSON.parse` throws, and a throw is invisible to the type system — so the parse lives inside the + * schema, where a body that is not JSON and a body that is not this payload are the same `None`. + * They mean the same thing at the ingress anyway: Slack sent something with no mapping here. + */ +export const decodeEventRequest = Schema.decodeUnknownOption(Schema.fromJsonString(EventRequest)) + +/** + * A `block_actions` payload, as the interactivity URL receives it (form-encoded under `payload=`). + * + * `user` carries `id`, `username`, `name` and `team_id` and NOTHING about membership: no roles, no + * `is_admin`, no `is_owner`. That is a Slack fact, not an omission here — see `events.ts` for what + * this connector reports because of it. + */ +export const BlockActions = Schema.Struct({ + type: Schema.Literal("block_actions"), + team: Schema.optionalKey(Schema.Struct({ id: Schema.optionalKey(Schema.String) })), + user: Schema.Struct({ + id: Schema.String, + username: Schema.optionalKey(Schema.String), + name: Schema.optionalKey(Schema.String), + team_id: Schema.optionalKey(Schema.String), + }), + channel: Schema.optionalKey(Schema.Struct({ id: Schema.optionalKey(Schema.String) })), + container: Schema.optionalKey( + Schema.Struct({ + channel_id: Schema.optionalKey(Schema.String), + message_ts: Schema.optionalKey(Schema.String), + }), + ), + message: Schema.optionalKey( + Schema.Struct({ + ts: Schema.optionalKey(Schema.String), + /** The thread the control's message sits in — Maple's answer, so its parent mention. */ + thread_ts: Schema.optionalKey(Schema.String), + }), + ), + actions: Schema.Array( + Schema.Struct({ + action_id: Schema.optionalKey(Schema.String), + value: Schema.optionalKey(Schema.String), + }), + ), +}) +export type SlackBlockActions = Schema.Schema.Type + +export const decodeBlockActions = Schema.decodeUnknownOption(Schema.fromJsonString(BlockActions)) + +/** + * The token response, for an authorization that installed the bot into ONE workspace. + * + * `team` is optional because Slack answers with `"team": null` for an enterprise-wide install, and + * `is_enterprise_install` is read so that case is refused by name rather than as "no team". + */ +export const TokenResponse = Schema.Struct({ + ok: Schema.Boolean, + error: Schema.optionalKey(Schema.String), + access_token: Schema.optionalKey(Schema.String), + is_enterprise_install: Schema.optionalKey(Schema.Boolean), + team: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + id: Schema.optionalKey(Schema.String), + name: Schema.optionalKey(Schema.String), + }), + ), + ), +}) + +export const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse) + +/** + * What a Web API call answers with. + * + * Slack reports most failures as HTTP 200 with `ok: false`, so the status code is not the answer — + * a transport that read only the status would file every revoked token as a success. + */ +export const ApiResult = Schema.Struct({ + ok: Schema.Boolean, + error: Schema.optionalKey(Schema.String), + channel: Schema.optionalKey(Schema.String), + ts: Schema.optionalKey(Schema.String), + /** + * A history page, left `Unknown` on purpose: each message is decoded on its own so one Maple + * cannot read costs that message rather than the conversation around it. + */ + messages: Schema.optionalKey(Schema.Array(Schema.Unknown)), +}) + +export const decodeApiResult = Schema.decodeUnknownEffect(ApiResult) + +/** + * One earlier message, as `conversations.history` and `conversations.replies` answer it. + * + * `text` is optional because a message can carry only blocks, files or attachments; the contract + * says an empty one is left out of the context rather than rendered as a blank line. `username` is + * what a bot-posted message carries instead of a `user`. + */ +const HistoryMessage = Schema.Struct({ + ts: Schema.String, + text: Schema.optionalKey(Schema.String), + user: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.String), + bot_id: Schema.optionalKey(Schema.String), + subtype: Schema.optionalKey(Schema.String), +}) + +export const decodeHistoryMessage = Schema.decodeUnknownOption(HistoryMessage) + +/** The bot's own user id for this delivery, where Slack named it. */ +export const botUserIdOf = (callback: SlackEventCallback): Option.Option => + Option.fromNullishOr( + callback.authorizations?.find((authorization) => authorization.is_bot === true)?.user_id, + ) diff --git a/packages/chat-platform/src/connectors/slack/render.test.ts b/packages/chat-platform/src/connectors/slack/render.test.ts new file mode 100644 index 0000000000..491e9225d9 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/render.test.ts @@ -0,0 +1,204 @@ +/** + * Blocks → a Slack body. The limits are the interesting part: Slack rejects a whole message over + * one oversized field, so a turn that overruns one must still be a turn. + */ +import { describe, expect, it } from "vitest" +import type { ChatActionToken } from "../../action-token" +import type { ChatBlock } from "../../render/blocks" +import { + APPROVE_ACTION, + DENY_ACTION, + EMPTY_TEXT, + MAX_BLOCKS, + MAX_BUTTON_VALUE_CHARS, + MAX_IMAGE_URL_CHARS, + MAX_SECTION_CHARS, + renderSlackMessage, +} from "./render" + +const token = (value: string) => value as ChatActionToken + +const prose = (markdown: string): ChatBlock => ({ kind: "prose", markdown }) + +describe("slack message bodies", () => { + it("renders prose as a mrkdwn section and repeats it as the notification fallback", () => { + const payload = renderSlackMessage([prose("**checkout** is slow")]) + expect(payload.blocks).toEqual([ + { type: "section", text: { type: "mrkdwn", text: "*checkout* is slow" } }, + ]) + // What a push notification and a screen reader read. + expect(payload.text).toBe("**checkout** is slow") + expect(payload.unfurl_links).toBe(false) + }) + + it("answers an empty turn with something Slack will accept", () => { + // Slack rejects a post carrying neither text nor blocks, and the driver empties a message a + // retraction shrank a turn past. + const payload = renderSlackMessage([]) + expect(payload.text).toBe(EMPTY_TEXT) + expect(payload.blocks).toEqual([{ type: "section", text: { type: "mrkdwn", text: EMPTY_TEXT } }]) + }) + + it("clamps a section rather than letting Slack refuse the message", () => { + const payload = renderSlackMessage([prose("x".repeat(MAX_SECTION_CHARS * 2))]) + const block = payload.blocks[0] + expect(block?.type === "section" && block.text.text.length).toBe(MAX_SECTION_CHARS) + }) + + it("keeps the first blocks when a turn runs past Slack's block limit", () => { + const payload = renderSlackMessage( + Array.from({ length: MAX_BLOCKS + 10 }, (_, index) => prose(`line ${index}`)), + ) + expect(payload.blocks).toHaveLength(MAX_BLOCKS) + const first = payload.blocks[0] + expect(first?.type === "section" && first.text.text).toBe("line 0") + }) +}) + +describe("charts", () => { + const chart = (imageUrl: string | null): ChatBlock => ({ + kind: "chart", + spec: { type: "line" } as never, + unit: "ms", + title: "p99", + summary: "p99 latency over 24h, peaking at 2.1s", + imageUrl, + }) + + it("shows a picture with the required alt text beside its summary", () => { + const payload = renderSlackMessage([chart("https://app.maple.dev/chart.png")]) + expect(payload.blocks[1]).toEqual({ + type: "image", + image_url: "https://app.maple.dev/chart.png", + alt_text: "p99 latency over 24h, peaking at 2.1s", + }) + }) + + it("falls back to the summary when there is no image, so the numbers are not lost", () => { + const payload = renderSlackMessage([chart(null)]) + expect(payload.blocks).toHaveLength(1) + expect(payload.blocks[0]?.type).toBe("section") + }) + + it("escapes a titleless chart's summary, which is model-authored like any other", () => { + const payload = renderSlackMessage([ + { + kind: "chart", + spec: { type: "line" } as never, + unit: "ms", + title: null, + summary: "p99 for & <@U0123>", + imageUrl: null, + }, + ]) + const block = payload.blocks[0] + expect(block?.type === "section" && block.text.text).toBe( + "p99 for <!channel> & <@U0123>", + ) + }) + + it("drops an over-long image URL rather than sending a message Slack rejects", () => { + const payload = renderSlackMessage([ + chart(`https://app.maple.dev/${"x".repeat(MAX_IMAGE_URL_CHARS)}`), + ]) + expect(payload.blocks.some((block) => block.type === "image")).toBe(false) + }) +}) + +describe("approvals", () => { + const approval = (value: string): ChatBlock => ({ + kind: "approval", + toolName: "create_dashboard", + summary: "Creates a dashboard called Checkout", + token: token(value), + outcome: null, + }) + + it("carries the action token on both buttons, unchanged", () => { + const payload = renderSlackMessage([approval("sess-1|call-1")]) + const actions = payload.blocks[1] + expect(actions?.type === "actions" && actions.elements).toEqual([ + { + type: "button", + text: { type: "plain_text", text: "Run create_dashboard" }, + action_id: APPROVE_ACTION, + value: "sess-1|call-1", + style: "primary", + }, + { + type: "button", + text: { type: "plain_text", text: "Skip" }, + action_id: DENY_ACTION, + value: "sess-1|call-1", + style: "danger", + }, + ]) + }) + + it("keeps a settled proposal's line and loses its buttons", () => { + // Slack leaves a button clickable forever, so a decided proposal must stop offering one — + // the click would only ever be answered "settled". + const payload = renderSlackMessage([ + { + kind: "approval", + toolName: "create_dashboard", + summary: "Creates a dashboard called Checkout", + token: token("sess-1|call-1"), + outcome: { approved: true, text: "Ada approved this." }, + }, + ]) + expect(payload.blocks.some((block) => block.type === "actions")).toBe(false) + const block = payload.blocks[0] + expect(block?.type === "section" && block.text.text).toContain("Ada approved this.") + }) + + it("says so rather than sending buttons Slack would reject the message over", () => { + const payload = renderSlackMessage([approval("x".repeat(MAX_BUTTON_VALUE_CHARS + 1))]) + expect(payload.blocks.some((block) => block.type === "actions")).toBe(false) + expect(payload.text).toContain("Approve create_dashboard?") + }) +}) + +describe("the rest of the vocabulary", () => { + it("links an entity and escapes its label", () => { + const payload = renderSlackMessage([ + { + kind: "entity", + entity: "trace", + label: "GET /checkout & pay", + detail: "2.1s", + url: "https://app.maple.dev/traces/abc", + }, + ]) + const block = payload.blocks[0] + expect(block?.type === "section" && block.text.text).toBe( + "*Trace* \n2.1s", + ) + }) + + it("puts tool activity in a context block, not a card per call", () => { + const payload = renderSlackMessage([ + { + kind: "activity", + tools: [ + { name: "search_traces", status: "done", detail: null }, + { name: "run_sql", status: "running", detail: null }, + ], + }, + ]) + expect(payload.blocks[0]).toEqual({ + type: "context", + elements: [{ type: "mrkdwn", text: "Tools: `search_traces` · `run_sql`…" }], + }) + }) + + it("renders an error notice loudly and everything else quietly", () => { + expect(renderSlackMessage([{ kind: "notice", tone: "error", text: "it broke" }]).blocks[0]).toEqual({ + type: "section", + text: { type: "mrkdwn", text: "*it broke*" }, + }) + expect(renderSlackMessage([{ kind: "notice", tone: "info", text: "thinking" }]).blocks[0]?.type).toBe( + "context", + ) + }) +}) diff --git a/packages/chat-platform/src/connectors/slack/render.ts b/packages/chat-platform/src/connectors/slack/render.ts new file mode 100644 index 0000000000..2bb2665baf --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/render.ts @@ -0,0 +1,275 @@ +/** + * Blocks → a Slack message body. + * + * Pure, and the only place Slack's dialect is written down beside `mrkdwn.ts`: its Block Kit + * vocabulary, its buttons, and the limits that decide what a body may contain. + * + * Verified against Slack's Block Kit reference: a message carries at most 50 blocks, a `section`'s + * `text` at most 3000 characters, an `actions` block at most 25 elements, a button's `text` at most + * 75 characters and its `value` at most 2000, a `context` block at most 10 elements, an `image` + * block's `image_url` at most 3000 characters with a REQUIRED `alt_text` of at most 2000, and a + * message's top-level `text` at most 40000. + */ +import type { ChatBlock, ChatToolActivity } from "../../render/blocks" +import { escapeMrkdwn, toMrkdwn } from "./mrkdwn" + +export interface SlackText { + readonly type: "mrkdwn" + readonly text: string +} + +export interface SlackSection { + readonly type: "section" + readonly text: SlackText +} + +export interface SlackContext { + readonly type: "context" + readonly elements: ReadonlyArray +} + +export interface SlackImage { + readonly type: "image" + readonly image_url: string + readonly alt_text: string + readonly title?: { readonly type: "plain_text"; readonly text: string } +} + +export interface SlackButton { + readonly type: "button" + readonly text: { readonly type: "plain_text"; readonly text: string } + readonly action_id: string + readonly value: string + /** Slack's two emphatic styles; anything else is the default grey. */ + readonly style?: "primary" | "danger" +} + +export interface SlackActions { + readonly type: "actions" + readonly elements: ReadonlyArray +} + +export type SlackBlock = SlackSection | SlackContext | SlackImage | SlackActions + +export interface SlackMessagePayload { + /** + * The notification and accessibility fallback. Slack recommends it on every message that + * carries blocks — it is what a push notification and a screen reader read — and it is the one + * field a client with no Block Kit support shows. + */ + readonly text: string + readonly blocks: ReadonlyArray + /** Model-authored text should not turn a link into a preview card in somebody's channel. */ + readonly unfurl_links: false + readonly unfurl_media: false +} + +/** + * One `chat.postMessage` or `chat.update` body: a rendered message plus where it goes. + * + * `thread_ts` names the thread on a post and is absent on an update, which addresses the message by + * its own `ts` — so both are optional and the transport supplies whichever the call takes. + */ +export interface SlackMessageRequest extends SlackMessagePayload { + readonly channel: string + readonly thread_ts?: string + readonly ts?: string +} + +/** + * One read of a conversation: `conversations.history` for a channel, `conversations.replies` for a + * thread, which is the same arguments plus the parent message's `ts`. + */ +export interface SlackHistoryRequest { + readonly channel: string + readonly limit: number + /** The end of the range. With `inclusive: false` this is "everything before this message". */ + readonly latest: string + readonly inclusive: false + /** The thread's parent, for `conversations.replies` only. */ + readonly ts?: string +} + +/** Everything the transport sends. Both are JSON bodies to a Web API method. */ +export type SlackApiRequest = SlackMessageRequest | SlackHistoryRequest + +export const MAX_BLOCKS = 50 +export const MAX_SECTION_CHARS = 3000 +export const MAX_BUTTON_TEXT_CHARS = 75 +export const MAX_BUTTON_VALUE_CHARS = 2000 +export const MAX_IMAGE_URL_CHARS = 3000 +export const MAX_ALT_TEXT_CHARS = 2000 +export const MAX_FALLBACK_CHARS = 40000 + +export const APPROVE_ACTION = "maple_approve" +export const DENY_ACTION = "maple_deny" + +/** A message with nothing in it: Slack rejects a post carrying neither text nor blocks. */ +export const EMPTY_TEXT = "—" + +const clamp = (text: string, max: number): string => + text.length <= max ? text : `${text.slice(0, max - 1)}…` + +const section = (text: string): SlackSection => ({ + type: "section", + text: { type: "mrkdwn", text: clamp(text, MAX_SECTION_CHARS) }, +}) + +const context = (text: string): SlackContext => ({ + type: "context", + elements: [{ type: "mrkdwn", text: clamp(text, MAX_SECTION_CHARS) }], +}) + +const ENTITY_LABELS = { + trace: "Trace", + service: "Service", + error: "Error", + log: "Log", +} as const + +/** + * Tool names carry underscores, which mrkdwn reads as italics, so a name is always in backticks + * rather than the surrounding line being styled. + */ +const toolLabel = (tool: ChatToolActivity): string => { + const detail = tool.detail === null ? "" : ` (${escapeMrkdwn(tool.detail)})` + const status = tool.status === "running" ? "…" : tool.status === "failed" ? " (failed)" : "" + return `\`${escapeMrkdwn(tool.name)}\`${detail}${status}` +} + +/** + * The two buttons, carrying the driver's action token in a button's `value`. + * + * A tool call id is assigned by the model provider, so the token's length is not ours to bound — but + * Slack's 2000 characters is wide enough that a token which does not fit is a token that is wrong. + * A pair that would not fit is dropped rather than sent, because Slack rejects the whole message + * over one oversized value. + */ +const approvalActions = (token: string, toolName: string): SlackActions | null => { + if (token.length > MAX_BUTTON_VALUE_CHARS) return null + return { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: clamp(`Run ${toolName}`, MAX_BUTTON_TEXT_CHARS) }, + action_id: APPROVE_ACTION, + value: token, + style: "primary", + }, + { + type: "button", + text: { type: "plain_text", text: "Skip" }, + action_id: DENY_ACTION, + value: token, + style: "danger", + }, + ], + } +} + +/** + * A chart, as a picture where there is one and as its summary where there is not. + * + * `alt_text` is required by Slack, and the summary is exactly what it is for: the sentence that + * stands in for the plot. The URL is bounded because an over-long one is not a broken image, it is + * a rejected message — and with it the whole turn. + */ +const chartBlocks = (block: Extract): ReadonlyArray => { + // Both branches escape. A chart's title and summary are built from model-authored chart JSON, + // so an unescaped one is the same hole as an unescaped paragraph: `` in a summary + // would page the workspace. + const heading = + block.title === null + ? escapeMrkdwn(block.summary) + : `*${escapeMrkdwn(block.title)}* — ${escapeMrkdwn(block.summary)}` + if (block.imageUrl === null || block.imageUrl.length > MAX_IMAGE_URL_CHARS) { + return [section(heading)] + } + return [ + section(heading), + { + type: "image", + image_url: block.imageUrl, + alt_text: clamp(block.summary === "" ? "Chart" : block.summary, MAX_ALT_TEXT_CHARS), + }, + ] +} + +/** What one message's worth of blocks becomes. */ +export const renderSlackMessage = (blocks: ReadonlyArray): SlackMessagePayload => { + const rendered: Array = [] + /** The fallback text, built from the same content the blocks are. */ + const fallback: Array = [] + + for (const block of blocks) { + switch (block.kind) { + case "prose": { + const text = toMrkdwn(block.markdown) + if (text.trim() !== "") rendered.push(section(text)) + fallback.push(block.markdown) + break + } + case "chart": + rendered.push(...chartBlocks(block)) + fallback.push(block.title === null ? block.summary : `${block.title} — ${block.summary}`) + break + case "entity": { + const label = escapeMrkdwn(block.label) + const link = block.url === null ? label : `<${block.url}|${label}>` + const detail = block.detail === null ? "" : `\n${escapeMrkdwn(block.detail)}` + rendered.push(section(`*${ENTITY_LABELS[block.entity]}* ${link}${detail}`)) + fallback.push(`${ENTITY_LABELS[block.entity]} ${block.label}`) + break + } + case "activity": + if (block.tools.length > 0) { + rendered.push(context(`Tools: ${block.tools.map(toolLabel).join(" · ")}`)) + fallback.push(`Tools: ${block.tools.map((tool) => tool.name).join(", ")}`) + } + break + case "approval": { + const summary = block.summary === "" ? "" : `\n${escapeMrkdwn(block.summary)}` + if (block.outcome !== null) { + // A decided proposal keeps its line and loses its buttons: Slack leaves a button + // clickable forever, and the click would only ever be answered "settled". + rendered.push( + section( + `*\`${escapeMrkdwn(block.toolName)}\`*${summary}\n${escapeMrkdwn(block.outcome.text)}`, + ), + ) + fallback.push(`${block.toolName}: ${block.outcome.text}`) + break + } + rendered.push(section(`*Approve \`${escapeMrkdwn(block.toolName)}\`?*${summary}`)) + const actions = approvalActions(block.token, block.toolName) + if (actions === null) rendered.push(section("This one has to be approved in Maple.")) + else rendered.push(actions) + fallback.push(`Approve ${block.toolName}?`) + break + } + case "notice": + rendered.push( + block.tone === "error" + ? section(`*${escapeMrkdwn(block.text)}*`) + : context(escapeMrkdwn(block.text)), + ) + fallback.push(block.text) + break + default: + // A block kind added to the neutral model but not to this dialect would otherwise + // render as nothing at all. + block satisfies never + } + } + + const text = fallback.join("\n\n").trim() + return { + text: clamp(text === "" ? EMPTY_TEXT : text, MAX_FALLBACK_CHARS), + // The cut is at the block level and keeps the FIRST blocks: a turn's answer opens with what + // it found, and the tail of a long one is its working. + blocks: rendered.length === 0 ? [section(EMPTY_TEXT)] : rendered.slice(0, MAX_BLOCKS), + unfurl_links: false, + unfurl_media: false, + } +} diff --git a/packages/chat-platform/src/connectors/slack/signature.test.ts b/packages/chat-platform/src/connectors/slack/signature.test.ts new file mode 100644 index 0000000000..69895e2754 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/signature.test.ts @@ -0,0 +1,101 @@ +/** + * The check everything else in this connector stands on. + * + * The expected digest is computed HERE, from Slack's documented base string, rather than imported + * from the module under test — so a bug in how the subject builds that string cannot also produce + * the fixture that proves it right. + */ +import { createHmac } from "node:crypto" +import { describe, expect, it } from "vitest" +import { MAX_TIMESTAMP_SKEW_SECONDS } from "./api" +import { verifySlackSignature } from "./signature" + +const SECRET = "8f742231b10e8888abcd99yyyzzz85a5" + +/** Slack's own documented example body. */ +const BODY = + "token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&api_app_id=A0F7YS25R&event=%7B%22type%22%3A%22app_mention%22%7D" + +const NOW = 1_700_000_000_000 + +const seconds = (now: number) => String(Math.floor(now / 1000)) + +const sign = (timestamp: string, body: string, secret = SECRET) => + `v0=${createHmac("sha256", secret).update(`v0:${timestamp}:${body}`, "utf8").digest("hex")}` + +const verify = (overrides: Partial[0]> = {}) => + verifySlackSignature({ + signature: sign(seconds(NOW), BODY), + timestamp: seconds(NOW), + body: BODY, + signingSecret: SECRET, + now: NOW, + ...overrides, + }) + +describe("slack request signatures", () => { + it("accepts a request signed with this app's secret", () => { + expect(verify()).toEqual({ _tag: "ok" }) + }) + + it("rejects a body that was changed after it was signed", () => { + expect(verify({ body: `${BODY}&injected=1` })).toEqual({ + _tag: "rejected", + reason: "signature mismatch", + }) + }) + + it("rejects a valid signature from a different app", () => { + const timestamp = seconds(NOW) + expect(verify({ signature: sign(timestamp, BODY, "another-apps-secret") })).toEqual({ + _tag: "rejected", + reason: "signature mismatch", + }) + }) + + it("rejects a correctly signed request that is too old to be live", () => { + // The whole point of the timestamp: this body's signature stays valid forever. + const old = NOW - (MAX_TIMESTAMP_SKEW_SECONDS + 1) * 1000 + expect(verify({ signature: sign(seconds(old), BODY), timestamp: seconds(old) })).toEqual({ + _tag: "rejected", + reason: "stale timestamp", + }) + }) + + it("rejects a timestamp from the future by the same margin", () => { + const ahead = NOW + (MAX_TIMESTAMP_SKEW_SECONDS + 1) * 1000 + expect(verify({ signature: sign(seconds(ahead), BODY), timestamp: seconds(ahead) })).toEqual({ + _tag: "rejected", + reason: "stale timestamp", + }) + }) + + it("accepts one right at the edge of the window", () => { + const edge = NOW - MAX_TIMESTAMP_SKEW_SECONDS * 1000 + expect(verify({ signature: sign(seconds(edge), BODY), timestamp: seconds(edge) })).toEqual({ + _tag: "ok", + }) + }) + + it("refuses a timestamp that is not plain digits", () => { + // `Number("1.7e12")` is a perfectly good instant, and nothing Slack ever sends. + expect(verify({ timestamp: "1.7e12" })).toEqual({ _tag: "rejected", reason: "malformed timestamp" }) + }) + + it("refuses a signature of the wrong shape before comparing anything", () => { + // A constant-time compare throws on unequal lengths, so a short value must never reach it. + expect(verify({ signature: "v0=deadbeef" })).toEqual({ + _tag: "rejected", + reason: "malformed signature", + }) + expect(verify({ signature: `v1=${"a".repeat(64)}` })).toEqual({ + _tag: "rejected", + reason: "malformed signature", + }) + }) + + it("refuses a request that carries no signature or no timestamp at all", () => { + expect(verify({ signature: undefined })).toEqual({ _tag: "rejected", reason: "missing signature" }) + expect(verify({ timestamp: undefined })).toEqual({ _tag: "rejected", reason: "missing timestamp" }) + }) +}) diff --git a/packages/chat-platform/src/connectors/slack/signature.ts b/packages/chat-platform/src/connectors/slack/signature.ts new file mode 100644 index 0000000000..e708c2b9c5 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/signature.ts @@ -0,0 +1,64 @@ +/** + * Proving a request came from Slack, before anything reads what it says. + * + * Pure and body-first: the caller hands over the RAW body text and this answers yes or no. Nothing + * here parses JSON, and nothing downstream runs until it has answered yes — a signature checked + * after the payload has been decoded and acted on is not a signature check. + * + * Slack's scheme, verified against its "Verifying requests from Slack" documentation: the base + * string is `v0:{X-Slack-Request-Timestamp}:{raw body}`, the digest is HMAC-SHA256 keyed with the + * app's Signing Secret, and `X-Slack-Signature` carries it as `v0=` plus lowercase hex. + */ +import { createHmac, timingSafeEqual } from "node:crypto" +import { MAX_TIMESTAMP_SKEW_SECONDS, SIGNATURE_VERSION } from "./api" + +export type SignatureVerdict = + | { readonly _tag: "ok" } + /** Why it was refused, in words safe to put in a 401 body and on a span. Never the digest. */ + | { readonly _tag: "rejected"; readonly reason: string } + +const OK: SignatureVerdict = { _tag: "ok" } + +const rejected = (reason: string): SignatureVerdict => ({ _tag: "rejected", reason }) + +/** `v0=` followed by 64 lowercase hex characters, and nothing else. */ +const SIGNATURE_PATTERN = new RegExp(`^${SIGNATURE_VERSION}=[0-9a-f]{64}$`) + +export interface SignatureInput { + readonly signature: string | undefined + readonly timestamp: string | undefined + readonly body: string + readonly signingSecret: string + /** Epoch MILLISECONDS, where Slack's header is seconds. The caller owns the clock. */ + readonly now: number +} + +/** + * Whether this request was signed by the app that holds this signing secret, recently. + * + * The two checks are deliberately in this order. The timestamp is rejected first because it costs + * nothing and closes the replay: a body captured off the wire stays correctly signed forever, and + * only its age says otherwise. + */ +export const verifySlackSignature = (input: SignatureInput): SignatureVerdict => { + if (input.signature === undefined) return rejected("missing signature") + if (input.timestamp === undefined) return rejected("missing timestamp") + // Digits only: `Number` would accept `1e9`, ` 12`, `0x…` and `Infinity`, and a timestamp that + // parses to something Slack would never send is a request to refuse, not one to interpret. + if (!/^\d+$/.test(input.timestamp)) return rejected("malformed timestamp") + const skew = Math.abs(input.now / 1000 - Number(input.timestamp)) + if (skew > MAX_TIMESTAMP_SKEW_SECONDS) return rejected("stale timestamp") + // Checked before the compare so the constant-time compare gets two buffers of equal length; an + // unequal-length `timingSafeEqual` throws, and catching that would leak the length through the + // branch it takes. + if (!SIGNATURE_PATTERN.test(input.signature)) return rejected("malformed signature") + + const expected = createHmac("sha256", input.signingSecret) + .update(`${SIGNATURE_VERSION}:${input.timestamp}:${input.body}`, "utf8") + .digest("hex") + const matches = timingSafeEqual( + Buffer.from(`${SIGNATURE_VERSION}=${expected}`, "utf8"), + Buffer.from(input.signature, "utf8"), + ) + return matches ? OK : rejected("signature mismatch") +} diff --git a/packages/chat-platform/src/connectors/slack/webhook.test.ts b/packages/chat-platform/src/connectors/slack/webhook.test.ts new file mode 100644 index 0000000000..75d5e3a77a --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/webhook.test.ts @@ -0,0 +1,216 @@ +/** + * The ingress end to end: a real signed HTTP request in, a response and normalized events out. + * + * Driven through `HttpServerRequest.fromWeb` rather than through the pieces, because the ordering + * is the property under test — an unsigned body must not reach a decoder, and Slack must be + * answered inside its own request whatever happened. + */ +import { createHmac } from "node:crypto" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { HttpServerRequest } from "effect/unstable/http" +import { MAX_TIMESTAMP_SKEW_SECONDS, SIGNING_SECRET_CONFIG } from "./api" + +/** The one thing a refused caller is ever told. */ +const REJECTED = "Slack could not be verified as the sender of this request" +import { slackIngress } from "./webhook" + +const SECRET = "8f742231b10e8888abcd99yyyzzz85a5" + +const config = new Map([[SIGNING_SECRET_CONFIG, SECRET]]) + +/** `it.effect` runs on a test clock that starts at the epoch, so that is what "now" is here. */ +const NOW = 0 + +const sign = (timestamp: string, body: string) => + `v0=${createHmac("sha256", SECRET).update(`v0:${timestamp}:${body}`, "utf8").digest("hex")}` + +const call = ( + body: string, + options: { + readonly contentType?: string + readonly headers?: Record + readonly signature?: string + readonly timestamp?: string + } = {}, +) => { + const timestamp = options.timestamp ?? String(Math.floor(NOW / 1000)) + const request = HttpServerRequest.fromWeb( + new Request("https://chat.maple.test/connectors/slack/webhook", { + method: "POST", + headers: { + "content-type": options.contentType ?? "application/json", + "x-slack-request-timestamp": timestamp, + "x-slack-signature": options.signature ?? sign(timestamp, body), + ...options.headers, + }, + body, + }), + ) + return slackIngress.handle(request, config) +} + +const mention = JSON.stringify({ + token: "z26uFbvR1xHJEdHE1OQiO6t8", + team_id: "T061EG9R6", + api_app_id: "A0MDYCDME", + type: "event_callback", + event_id: "Ev0MDYGDKJ", + event: { + type: "app_mention", + user: "U061F7AUR", + text: "<@U0LAN0Z89> why is checkout slow?", + ts: "1515449522.000016", + channel: "C0LAN2Q65", + }, + authorizations: [{ team_id: "T061EG9R6", user_id: "U0LAN0Z89", is_bot: true }], +}) + +describe("slack webhook ingress", () => { + it.effect("answers the URL-verification handshake with the challenge itself", () => + Effect.gen(function* () { + const body = JSON.stringify({ type: "url_verification", challenge: "abc123", token: "t" }) + const result = yield* call(body) + expect(result.events).toEqual([]) + expect(result.response.status).toBe(200) + const text = result.response.body + expect(text._tag === "Uint8Array" && new TextDecoder().decode(text.body)).toBe("abc123") + }), + ) + + it.effect("accepts a signed mention and hands it back as a normalized event", () => + Effect.gen(function* () { + const result = yield* call(mention) + expect(result.response.status).toBe(200) + expect(result.events).toEqual([ + { + type: "message", + connector: "slack", + workspaceId: "T061EG9R6", + channelId: "C0LAN2Q65", + threadId: "1515449522.000016", + messageId: "1515449522.000016", + author: { id: "U061F7AUR", displayName: "U061F7AUR", isBot: false }, + text: "why is checkout slow?", + mentionsBot: true, + }, + ]) + }), + ) + + it.effect("refuses a body that does not match its signature, and reads nothing out of it", () => + Effect.gen(function* () { + const error = yield* call(mention, { signature: `v0=${"0".repeat(64)}` }).pipe(Effect.flip) + expect(error._tag).toBe("@maple/chat-platform/ConnectorIngressError") + expect(error.message).toBe(REJECTED) + }), + ) + + it.effect("refuses a correctly signed body that is too old to be live", () => + Effect.gen(function* () { + const stale = String(MAX_TIMESTAMP_SKEW_SECONDS + 60) + const error = yield* call(mention, { timestamp: stale }).pipe(Effect.flip) + expect(error.message).toBe(REJECTED) + }), + ) + + it.effect("tells a refused caller nothing about which check refused it", () => + Effect.gen(function* () { + // The host returns this message as the 400 body. Naming the failing check tells an + // attacker which one to fix; the reason goes on the span instead. + const reasons = ["signature", "timestamp", "mismatch", "malformed", "stale", "missing"] + for (const options of [ + { signature: `v0=${"0".repeat(64)}` }, + { signature: "not-a-signature" }, + { timestamp: String(MAX_TIMESTAMP_SKEW_SECONDS + 60) }, + { timestamp: "not-a-timestamp" }, + ]) { + const error = yield* call(mention, options).pipe(Effect.flip) + expect(error.message).toBe(REJECTED) + for (const reason of reasons) expect(error.message.toLowerCase()).not.toContain(reason) + } + }), + ) + + it.effect("does not echo the challenge of an UNSIGNED handshake", () => + Effect.gen(function* () { + // The one branch that puts request content in the response. If verification ran after + // parsing, this would answer 200 with the challenge and Slack would accept any URL. + const body = JSON.stringify({ type: "url_verification", challenge: "abc123", token: "t" }) + const error = yield* call(body, { signature: `v0=${"0".repeat(64)}` }).pipe(Effect.flip) + expect(error._tag).toBe("@maple/chat-platform/ConnectorIngressError") + expect(error.message).not.toContain("abc123") + }), + ) + + it.effect("acknowledges a redelivery and relays nothing, so one question is answered once", () => + Effect.gen(function* () { + const result = yield* call(mention, { headers: { "x-slack-retry-num": "1" } }) + expect(result.response.status).toBe(200) + expect(result.events).toEqual([]) + }), + ) + + it.effect("reads a button press out of the form-encoded interactivity body", () => + Effect.gen(function* () { + const payload = JSON.stringify({ + type: "block_actions", + team: { id: "T061EG9R6" }, + user: { id: "U061F7AUR", name: "ada" }, + container: { type: "message", message_ts: "1515449522.000016", channel_id: "C0LAN2Q65" }, + message: { ts: "1515449522.000016", thread_ts: "1515449400.000001" }, + actions: [{ action_id: "maple_approve", value: "sess-1|call-1" }], + }) + const body = new URLSearchParams({ payload }).toString() + const result = yield* call(body, { contentType: "application/x-www-form-urlencoded" }) + expect(result.events).toEqual([ + { + type: "action", + connector: "slack", + workspaceId: "T061EG9R6", + channelId: "C0LAN2Q65", + threadId: "1515449400.000001", + messageId: "1515449522.000016", + actionToken: "sess-1|call-1", + actor: { id: "U061F7AUR", displayName: "ada" }, + }, + ]) + }), + ) + + it.effect("acknowledges an interaction that is not one of Maple's without relaying it", () => + Effect.gen(function* () { + const payload = JSON.stringify({ type: "view_submission", user: { id: "U1" } }) + const body = new URLSearchParams({ payload }).toString() + const result = yield* call(body, { contentType: "application/x-www-form-urlencoded" }) + expect(result.response.status).toBe(200) + expect(result.events).toEqual([]) + }), + ) + + it.effect("acknowledges a signed event it has no mapping for", () => + Effect.gen(function* () { + const body = JSON.stringify({ + team_id: "T061EG9R6", + type: "event_callback", + event: { type: "reaction_added", user: "U1", channel: "C1", ts: "1.0" }, + }) + const result = yield* call(body) + expect(result.response.status).toBe(200) + expect(result.events).toEqual([]) + }), + ) + + it.effect("refuses a request when the deployment holds no signing secret", () => + Effect.gen(function* () { + const request = HttpServerRequest.fromWeb( + new Request("https://chat.maple.test/connectors/slack/webhook", { + method: "POST", + body: mention, + }), + ) + const error = yield* slackIngress.handle(request, new Map()).pipe(Effect.flip) + expect(error.message).toContain("no signing secret") + }), + ) +}) diff --git a/packages/chat-platform/src/connectors/slack/webhook.ts b/packages/chat-platform/src/connectors/slack/webhook.ts new file mode 100644 index 0000000000..06eff16e02 --- /dev/null +++ b/packages/chat-platform/src/connectors/slack/webhook.ts @@ -0,0 +1,163 @@ +/** + * Slack's half of `POST /connectors/slack/webhook`. + * + * One route carries three different things — the URL-verification handshake, delivered events, and + * interactivity payloads — because Slack's app configuration takes one request URL per surface and + * this connector points all of them here. What arrives is told apart by its content type and then + * by its `type` field, never by a path. + * + * The order in this file IS the security property: the raw body is read, the signature is verified + * against it, and only then is a byte of it parsed. Everything below the check runs on text the + * app's signing secret vouched for. + * + * Nothing is awaited that Slack is waiting on. The handler answers 200 and hands the events back + * to the host, which relays them afterwards — Slack drops an event that is not acknowledged within + * three seconds and retries it, and an agent turn is not a three-second operation. + */ +import { Clock, Effect, Option } from "effect" +import { HttpServerResponse, type HttpServerRequest } from "effect/unstable/http" +import { + ConnectorIngressError, + type ConnectorConfig, + type InboundEvent, + type WebhookIngress, + type WebhookIngressResult, +} from "../../ingress" +import { RETRY_HEADER, SIGNATURE_HEADER, SIGNING_SECRET_CONFIG, TIMESTAMP_HEADER } from "./api" +import { blockActionsToInbound, eventCallbackToInbound } from "./events" +import { SLACK_CONNECTOR_ID } from "./id" +import { decodeBlockActions, decodeEventRequest } from "./payloads" +import { verifySlackSignature } from "./signature" + +/** + * Everything Slack is told when a request is accepted, and it is always the same. + * + * Slack reads no body except on the URL-verification handshake. + */ +const accepted = HttpServerResponse.empty({ status: 200 }) + +/** + * What a refused request is told, and it is always the same too. + * + * Telling a caller WHICH check refused it is telling an attacker which one to fix — and the host + * returns this message as the 400 body. The reason is put on the span instead (the route's own + * `chat_bot.connector_webhook` span, which is current here), where it is a six-value enum an + * operator can break rejections down by. + */ +const REJECTED_MESSAGE = "Slack could not be verified as the sender of this request" + +const rejected = (reason: string, cause?: unknown) => + new ConnectorIngressError({ + connector: SLACK_CONNECTOR_ID, + message: reason, + ...(cause === undefined ? undefined : { cause }), + }) + +/** + * Nothing happened, and Slack is told it went fine — which is what stops the retry. + * + * Annotated rather than silent: an acknowledged-and-dropped request and a handled mention are the + * same 200 on the same span, and the difference is the whole dedup and filtering policy. + */ +const dropped = (reason: string): Effect.Effect => + Effect.as(Effect.annotateCurrentSpan({ "maple.chat.dropped": reason }), { + response: accepted, + events: [], + }) + +const result = (events: ReadonlyArray): WebhookIngressResult => ({ + response: accepted, + events, +}) + +/** + * Slack's form-encoded interactivity body, which carries its JSON under one field. + * + * Read off the raw text rather than through a request-body helper, because the text is what was + * signed and re-reading the body would be a second parse of something that might not match it. + */ +const interactivityPayload = (body: string): string | undefined => + new URLSearchParams(body).get("payload") ?? undefined + +const isFormEncoded = (request: HttpServerRequest.HttpServerRequest): boolean => + (request.headers["content-type"] ?? "").includes("application/x-www-form-urlencoded") + +/** + * One event, one delivery. + * + * Slack retries an event it believes was not acknowledged, up to three times. This handler answers + * inside its own request, so a retry means Slack did not hear the 200 rather than that the event + * went unhandled — and relaying it again would answer the same question twice in the same thread. + * Acknowledged and dropped. + * + * That is the whole deduplication story, deliberately: `event_id` deduplication needs storage, and + * ingress is a pure function the host may run in any isolate. Slack's own retry header is the one + * duplicate signal available without inventing that storage, and it covers the case that actually + * happens. + */ +const isRetry = (request: HttpServerRequest.HttpServerRequest): boolean => + request.headers[RETRY_HEADER] !== undefined + +const handle = Effect.fnUntraced(function* ( + request: HttpServerRequest.HttpServerRequest, + config: ConnectorConfig, +) { + const signingSecret = config.get(SIGNING_SECRET_CONFIG) + if (signingSecret === undefined) { + // Unreachable through the host, which skips a connector whose declared configuration is + // missing. Present so this can never degrade into an unverified mode if that ever changes. + return yield* Effect.fail(rejected("Slack ingress has no signing secret")) + } + const body = yield* request.text.pipe( + Effect.mapError((cause) => rejected("Slack's request body could not be read", cause)), + ) + const now = yield* Clock.currentTimeMillis + const verdict = verifySlackSignature({ + signature: request.headers[SIGNATURE_HEADER], + timestamp: request.headers[TIMESTAMP_HEADER], + body, + signingSecret, + now, + }) + if (verdict._tag === "rejected") { + yield* Effect.annotateCurrentSpan({ "maple.chat.reject_reason": verdict.reason }) + return yield* Effect.fail(rejected(REJECTED_MESSAGE)) + } + + if (isFormEncoded(request)) { + const payload = interactivityPayload(body) + if (payload === undefined) return yield* dropped("no interactivity payload") + return yield* Option.match(decodeBlockActions(payload), { + // A modal submission, a shortcut, a view close — all arrive here and none of them is + // something Maple rendered. Acknowledged so Slack stops asking. + onNone: () => dropped("not a block action"), + onSome: (actions) => Effect.succeed(result(blockActionsToInbound(actions))), + }) + } + + return yield* Option.match(decodeEventRequest(body), { + // A body that is not JSON, and a body that is JSON but not one of the two payloads this + // connector reads, are the same thing here: signed, and nothing to do about it. + onNone: () => dropped("unreadable event"), + onSome: (request_) => { + if (request_.type === "url_verification") { + // Answered inline, and the one case with a body: Slack accepts the bare challenge as + // text, which is the narrowest of the three forms it documents. It is reached only + // past the signature check above — an unsigned request never sees this line. + return Effect.succeed({ + response: HttpServerResponse.text(request_.challenge), + events: [], + }) + } + return isRetry(request) + ? dropped("redelivery") + : Effect.succeed(result(eventCallbackToInbound(request_))) + }, + }) +}) + +export const slackIngress: WebhookIngress = { + kind: "webhook", + requiredConfig: [{ name: SIGNING_SECRET_CONFIG, secret: true }], + handle, +} diff --git a/packages/chat-platform/src/ingress.ts b/packages/chat-platform/src/ingress.ts index 8e49d7e524..e270f029b3 100644 --- a/packages/chat-platform/src/ingress.ts +++ b/packages/chat-platform/src/ingress.ts @@ -109,6 +109,16 @@ export const InboundAction = Schema.Struct({ connector: ChatConnectorId, workspaceId: Schema.String, channelId: Schema.String, + /** + * The sub-conversation the click landed in, where the platform models one separately from the + * channel — the same coordinate {@link InboundMessage} carries, and absent for the same reason: + * on a platform where a thread IS a channel, `channelId` already addresses it. + * + * It is what `transport.conversation` names an action's conversation from, and that name is + * what scopes the approval — so a platform that has threads must report it, or a click would + * resolve to a different conversation than the proposal it is answering. + */ + threadId: Schema.optionalKey(Schema.String), /** The message carrying the control, so the reply can update it in place. */ messageId: Schema.String, /** diff --git a/packages/chat-platform/src/install.ts b/packages/chat-platform/src/install.ts index 26c64aa3f1..47b84b1e39 100644 --- a/packages/chat-platform/src/install.ts +++ b/packages/chat-platform/src/install.ts @@ -111,6 +111,18 @@ export interface ChatInstallCallback { export interface ChatInstallResult { readonly externalWorkspaceId: string readonly name: string + /** + * A secret the install minted for THIS workspace, in whatever the connector + * wants to read back — one token, or its own JSON. Absent where a connector + * authenticates with one deployment-wide credential the host already resolves + * from its environment. + * + * Opaque above the connector: the host encrypts it, stores it beside the row, + * and hands it back to the same connector's outbound half under + * {@link WORKSPACE_CREDENTIALS}. Nothing between the two reads it, so a + * platform whose install returns three values needs no column for each. + */ + readonly credentials?: string | undefined } export interface ChatConnectorInstall { diff --git a/packages/chat-platform/src/outbound.ts b/packages/chat-platform/src/outbound.ts index 4f506973ab..f96551bbbf 100644 --- a/packages/chat-platform/src/outbound.ts +++ b/packages/chat-platform/src/outbound.ts @@ -26,6 +26,21 @@ export class ConnectorCredentials extends Context.Service statement-breakpoint +ALTER TABLE "chat_workspaces" ADD COLUMN "credentials_iv" text;--> statement-breakpoint +ALTER TABLE "chat_workspaces" ADD COLUMN "credentials_tag" text; \ No newline at end of file diff --git a/packages/db/drizzle/20260923192225_chat_workspace_credentials/snapshot.json b/packages/db/drizzle/20260923192225_chat_workspace_credentials/snapshot.json new file mode 100644 index 0000000000..2d32698ac3 --- /dev/null +++ b/packages/db/drizzle/20260923192225_chat_workspace_credentials/snapshot.json @@ -0,0 +1,16028 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "2e5167bc-9197-4ee7-8368-9bd1bba3f894", + "prevIds": [ + "285a924b-1a39-417f-99fa-b31ed02fa922" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "ai_triage_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_delivery_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_destinations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_incidents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_rule_claims", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_rule_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_rules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "anomaly_detector_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "anomaly_detector_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "anomaly_incidents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "api_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "chat_identities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "chat_workspaces", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloudflare_analytics_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloudflare_hyperdrive_configs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloudflare_logpush_connectors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cli_device_authorizations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mcp_oauth_authorizations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mcp_oauth_clients", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mcp_oauth_refresh_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "dashboard_shares", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "dashboard_versions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "dashboards", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "digest_subscriptions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_fingerprint_candidates", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_incidents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_pull_requests", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_verifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issues", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_notification_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_tick_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "issue_escalation_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "issue_escalations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "investigations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oauth_auth_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oauth_connections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_onboarding_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_ingest_attribute_mappings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_recommendation_issues", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_ingest_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_ingest_sampling_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_clickhouse_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_clickhouse_schema_apply_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_connections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_databases", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_issue_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_poll_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "scrape_target_checks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "scrape_targets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slack_workspaces", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pr_reviews", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_commits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_installations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_repositories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_repository_branches", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "20", + "generated": null, + "identity": null, + "name": "max_runs_per_day", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "90", + "generated": null, + "identity": null, + "name": "max_passes_per_day", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "destination_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_key", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_type", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attempt_number", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_expires_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_by", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attempted_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_message", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_reference", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "response_code", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config_json", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_iv", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_tag", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_tested_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_test_error", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_failures", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_failure_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_reason", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_key", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_name", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_key", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comparator", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold_upper", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_observed_value", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupe_key", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_delivered_event_type", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_notified_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hold_reason", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "held_since", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_issue_id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'__total__'", + "generated": null, + "identity": null, + "name": "group_key", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_breaches", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_healthy", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_status", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_value", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notification_template_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_names_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exclude_service_names_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environments_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tags_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comparator", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold_upper", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "window_minutes", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "minimum_sample_count", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "consecutive_breaches_required", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "consecutive_healthy_required", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "30", + "generated": null, + "identity": null, + "name": "renotify_interval_minutes", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apdex_threshold_ms", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "query_builder_draft_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "raw_query_sql", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_by", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "destination_ids_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "query_spec_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reducer", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sample_count_strategy", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "no_data_behavior", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'normal'", + "generated": null, + "identity": null, + "name": "sensitivity", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "muted_signals_json", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_tick_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "detector_key", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "deployment_env", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_breaches", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_healthy", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_status", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseline_median", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "open_incident_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_resolved_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_incident_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "detector_key", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "deployment_env", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_issue_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "opened_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseline_median", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseline_sigma", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_observed_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolve_reason", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "triage_status", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupe_key", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "fingerprints_json", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "reopen_count", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_reopened_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key_hash", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key_prefix", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "revoked", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_used_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metadata_json", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'standard'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_email", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connector", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_user_id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_name", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connector", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_workspace_id", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settings", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentials_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentials_iv", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentials_tag", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dataset", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "zone_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "zone_name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "watermark_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "backfill_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settings_json", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settings_fetched_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "quantiles_available", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "discovered_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "live_scripts_json", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_success_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_until", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_host", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_port", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_scheme", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_database", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_user", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "zone_name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'http_requests'", + "generated": null, + "identity": null, + "name": "dataset", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_iv", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_tag", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_hash", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_received_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_rotated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_code_hash", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_code_hash", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_org_id", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_id", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_roles", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_email", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api_key_id", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_iv", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_tag", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "denied_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "request_id_hash", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_name", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uri", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code_challenge", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorization_code_hash", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_org_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_roles", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_email", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "denied_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "used_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_name", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uris", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_uri", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_hash", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "family_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roles", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_email", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_key_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "replaced_by_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "family_expires_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "live_activity_start_token", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_reason", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_pushed_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dashboard_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "widget_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_hash", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_iv", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_tag", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dashboard_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version_number", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot_json", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "change_kind", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "change_summary", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_version_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "opted_out_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "day_of_week", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "namespaces_json", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "environments_json", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sent_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_attempted_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "agent_name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "capabilities_json", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_active_at", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_type", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_message", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "error_label", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "top_frame", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "service_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "from_state", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "to_state", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_repo_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repo_full_name", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_login", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merged_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merge_commit_sha", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "link_source", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "linked_by_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_observed_occurrence_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "open_incident_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pull_request_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'waiting'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merged_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verify_after", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "baseline_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseline_occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseline_rate_per_hour", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "investigation_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verdict", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verdict_note", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "post_merge_occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'error'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_ref_json", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "fingerprint_version", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_type", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_message", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "error_label", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "top_frame", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'triage'", + "generated": null, + "identity": null, + "name": "workflow_state", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity_source", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigned_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_holder_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_expires_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_by_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_resolved_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_regressed_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "regression_count", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "seen_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "resolved_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snooze_until", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archived_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "destination_id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_key", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempt_count", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_expires_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_by", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attempted_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "destination_ids_json", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notify_on_first_seen", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notify_on_regression", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_resolve", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_transition_in_review", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_transition_done", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_claim", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "min_occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'warning'", + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_through", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "bootstrap_completed", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_token", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_expires_at", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "rules_json", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "run_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "investigation_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "delivery_results_json", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'queued'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupe_key", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_at", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'investigating'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "seeded_by", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_kind", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "report_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "progress_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confidence", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "input_tokens", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "output_tokens", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "autonomous_turns", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "diagnosed_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_reason", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "initiated_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uri", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "return_to", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code_verifier", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_user_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_user_email", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_account_name", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "granted_accounts_json", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connected_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_iv", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_tag", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_iv", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_tag", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "demo_data_requested", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "onboarding_completed_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checklist_dismissed_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_data_received_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "welcome_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connect_nudge_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stalled_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reward_claimed_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reward_reserved_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_context", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_key", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_key", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recommendation_key", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_key", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canonical_key", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usage_count", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "opened_at", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key_hash", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_iv", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_tag", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_hash", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_rotated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_rotated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "trace_sample_ratio", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "always_keep_error_spans", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "always_keep_slow_spans_ms", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_url", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_user", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_password_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_password_iv", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_password_tag", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_database", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schema_version", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "workflow_instance_id", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phase", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "current_migration", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "steps_total", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "steps_done", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "applied_versions", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipped", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ps_organization", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connected_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scrape_target_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webhook_secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webhook_secret_iv", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webhook_secret_tag", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "detected_permissions_json", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_inventory_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_inventory_error", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "database_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'mysql'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "plan", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "branches_json", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "database_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "database_name", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "branch_name", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_type", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "external_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_login", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dataset", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "database_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "watermark_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_success_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_until", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "scrape_target_checks_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_id", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "sub_target_key", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checked_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "duration_ms", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "samples_scraped", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "samples_post_relabel", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'prometheus'", + "generated": null, + "identity": null, + "name": "target_type", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "discovery_config_json", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "15", + "generated": null, + "identity": null, + "name": "scrape_interval_seconds", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "labels_json", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "auth_type", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "managed_by", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_credentials_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_credentials_iv", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_credentials_tag", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scrape_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scrape_error", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "team_id", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "team_name", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bot_user_id", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bot_token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bot_token_iv", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bot_token_tag", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api_key_id", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api_key_secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api_key_secret_iv", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api_key_secret_tag", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "installed_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_reason", + "entityType": "columns", + "schema": "public", + "table": "slack_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "head_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "base_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'queued'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skip_reason", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "report_json", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "score", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "check_run_url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment_url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "review_url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publish_error", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "input_tokens", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "output_tokens", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sha", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_name", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_email", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_login", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_avatar_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authored_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "committed_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "html_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_installation_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_login", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_type", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_account_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_avatar_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'all'", + "generated": null, + "identity": null, + "name": "repository_selection", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "installed_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "installation_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_repo_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'main'", + "generated": null, + "identity": null, + "name": "default_branch", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tracked_branch", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "html_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "is_private", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_archived", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_synced_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "pr_review_enabled", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_default", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "head_sha", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_org_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduled_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "claim_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduled_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "attempt_number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_delivery_attempt_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_destinations_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_destinations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_destinations_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_destinations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_destinations_org_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_destinations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "rule_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_rule_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "error_issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "incident_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_incident_key_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "rule_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "group_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'open'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_open_group_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rule_claims_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rule_states_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rule_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rules_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rules_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rules_org_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "open_incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"open_incident_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_detector_states_open_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "last_evaluated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_detector_states_evaluated_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_triggered_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_status_triggered_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_triggered_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_triggered_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "detector_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_detector_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "error_issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "detector_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'open'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_open_detector_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "key_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_key_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_org_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "connector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_identities_org_connector_external_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "connector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_identities_org_connector_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_identities_org_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "connector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_workspace_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_workspaces_connector_external_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_workspaces_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dataset", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "zone_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cf_analytics_state_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "config_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_hyperdrive_configs_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_logpush_connectors_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_code_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cli_device_authorizations_user_code_unique", + "entityType": "indexes", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cli_device_authorizations_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorization_code_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_authorizations_code_unique", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_authorizations_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "token_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_refresh_tokens_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "family_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_refresh_tokens_family_idx", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_refresh_tokens_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "platform", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mobile_devices_org_platform_token_unique", + "entityType": "indexes", + "schema": "public", + "table": "mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mobile_devices_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mobile_devices_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "token_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_token_hash_unq", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "coalesce(widget_id, '')", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "revoked_at is null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_live_unq", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_org_dashboard_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version_number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_versions_org_dashboard_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_versions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version_number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_versions_org_dashboard_version_unq", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_versions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboards_org_updated_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboards" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboards_org_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboards" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "digest_subscriptions_org_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "digest_subscriptions_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actors_org_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "agent_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actors_org_agent_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actors_org_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_fingerprint_candidates_last_seen_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_incidents_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_triggered_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_incidents_org_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_events_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_events_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_events_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "repo_full_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_pull_requests_issue_pr_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "repo_full_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_pull_requests_repo_number_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_pull_requests_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "verify_after", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_verifications_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_verifications_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" in ('waiting', 'running')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_verifications_open_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fingerprint_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_fp_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "workflow_state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_workflow_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "severity", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_severity_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archived_at\" is null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_live_seen_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fingerprint_version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_fp_version_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "assigned_actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lease_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_lease_expiry_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "archived_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archived_at\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_archived_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduled_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "claim_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_notification_deliveries_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_notification_deliveries_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "destination_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_notification_deliveries_key_destination_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "claim_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_tick_states_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_tick_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "dedupe_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "issue_escalations_dedupe_idx", + "entityType": "indexes", + "schema": "public", + "table": "issue_escalations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "issue_escalations_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "issue_escalations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "issue_escalations_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "issue_escalations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"incident_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_org_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_org_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "device_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_activities_device_incident_unique", + "entityType": "indexes", + "schema": "public", + "table": "live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_activities_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oauth_auth_states_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oauth_connections_org_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "oauth_connections" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oauth_connections_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "oauth_connections" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_attribute_mappings_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_recommendation_issues_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "recommendation_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_recommendation_issues_org_key_idx", + "entityType": "indexes", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_keys_public_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "public_key_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_keys_public_key_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "private_key_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_keys_private_key_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_connections_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_connections" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_databases_org_db_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_databases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_databases_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_databases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "event_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_events_dedupe_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_events_org_db_time_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_events_org_time_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "processed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_issue_receipts_processed_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dataset", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_poll_state_org_dataset_db_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_poll_state_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "target_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "checked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "scrape_target_checks_target_checked_idx", + "entityType": "indexes", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "scrape_targets_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "scrape_targets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "scrape_targets_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "scrape_targets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "team_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slack_workspaces_team_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "slack_workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slack_workspaces_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "slack_workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"revoked_at\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "slack_workspaces_active_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "slack_workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "head_sha", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_reviews_repo_number_head_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_reviews_repo_number_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_reviews_org_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "sha", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_commits_repo_sha_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_commits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "sha", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_commits_org_sha_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_commits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_installation_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_installations_provider_external_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_installations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_installations_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_installations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_repo_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repositories_org_repo_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repositories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repositories_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repositories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "installation_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repositories_installation_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repositories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repository_branches_repo_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repository_branches_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "nameExplicit": true, + "columns": [ + "org_id", + "dashboard_id" + ], + "schemaTo": "public", + "tableTo": "dashboards", + "columnsTo": [ + "org_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "dashboard_shares_dashboard_fk", + "entityType": "fks", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": false, + "columns": [ + "target_id" + ], + "schemaTo": "public", + "tableTo": "scrape_targets", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "entityType": "fks", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "columns": [ + "org_id", + "rule_id", + "group_key" + ], + "nameExplicit": false, + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "entityType": "pks", + "schema": "public", + "table": "alert_rule_states" + }, + { + "columns": [ + "org_id", + "detector_key" + ], + "nameExplicit": false, + "name": "anomaly_detector_states_org_id_detector_key_pk", + "entityType": "pks", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "columns": [ + "org_id", + "id" + ], + "nameExplicit": false, + "name": "dashboard_shares_org_id_id_pk", + "entityType": "pks", + "schema": "public", + "table": "dashboard_shares" + }, + { + "columns": [ + "org_id", + "id" + ], + "nameExplicit": false, + "name": "dashboard_versions_org_id_id_pk", + "entityType": "pks", + "schema": "public", + "table": "dashboard_versions" + }, + { + "columns": [ + "org_id", + "id" + ], + "nameExplicit": false, + "name": "dashboards_org_id_id_pk", + "entityType": "pks", + "schema": "public", + "table": "dashboards" + }, + { + "columns": [ + "org_id", + "fingerprint_hash" + ], + "nameExplicit": false, + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "entityType": "pks", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "columns": [ + "org_id", + "issue_id" + ], + "nameExplicit": false, + "name": "error_issue_states_org_id_issue_id_pk", + "entityType": "pks", + "schema": "public", + "table": "error_issue_states" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_ingest_keys_org_id_pk", + "entityType": "pks", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_clickhouse_settings_org_id_pk", + "entityType": "pks", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "entityType": "pks", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "columns": [ + "org_id", + "event_id" + ], + "nameExplicit": false, + "name": "planetscale_issue_receipts_org_id_event_id_pk", + "entityType": "pks", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "ai_triage_settings_pkey", + "schema": "public", + "table": "ai_triage_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_delivery_events_pkey", + "schema": "public", + "table": "alert_delivery_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_destinations_pkey", + "schema": "public", + "table": "alert_destinations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_incidents_pkey", + "schema": "public", + "table": "alert_incidents", + "entityType": "pks" + }, + { + "columns": [ + "rule_id" + ], + "nameExplicit": false, + "name": "alert_rule_claims_pkey", + "schema": "public", + "table": "alert_rule_claims", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_rules_pkey", + "schema": "public", + "table": "alert_rules", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "anomaly_detector_settings_pkey", + "schema": "public", + "table": "anomaly_detector_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "anomaly_incidents_pkey", + "schema": "public", + "table": "anomaly_incidents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "api_keys_pkey", + "schema": "public", + "table": "api_keys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_identities_pkey", + "schema": "public", + "table": "chat_identities", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_workspaces_pkey", + "schema": "public", + "table": "chat_workspaces", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloudflare_analytics_state_pkey", + "schema": "public", + "table": "cloudflare_analytics_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloudflare_hyperdrive_configs_pkey", + "schema": "public", + "table": "cloudflare_hyperdrive_configs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloudflare_logpush_connectors_pkey", + "schema": "public", + "table": "cloudflare_logpush_connectors", + "entityType": "pks" + }, + { + "columns": [ + "device_code_hash" + ], + "nameExplicit": false, + "name": "cli_device_authorizations_pkey", + "schema": "public", + "table": "cli_device_authorizations", + "entityType": "pks" + }, + { + "columns": [ + "request_id_hash" + ], + "nameExplicit": false, + "name": "mcp_oauth_authorizations_pkey", + "schema": "public", + "table": "mcp_oauth_authorizations", + "entityType": "pks" + }, + { + "columns": [ + "client_id" + ], + "nameExplicit": false, + "name": "mcp_oauth_clients_pkey", + "schema": "public", + "table": "mcp_oauth_clients", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mcp_oauth_refresh_tokens_pkey", + "schema": "public", + "table": "mcp_oauth_refresh_tokens", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mobile_devices_pkey", + "schema": "public", + "table": "mobile_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "digest_subscriptions_pkey", + "schema": "public", + "table": "digest_subscriptions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_incidents_pkey", + "schema": "public", + "table": "error_incidents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issue_events_pkey", + "schema": "public", + "table": "error_issue_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issue_pull_requests_pkey", + "schema": "public", + "table": "error_issue_pull_requests", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issue_verifications_pkey", + "schema": "public", + "table": "error_issue_verifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issues_pkey", + "schema": "public", + "table": "error_issues", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_notification_deliveries_pkey", + "schema": "public", + "table": "error_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "error_notification_policies_pkey", + "schema": "public", + "table": "error_notification_policies", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "error_tick_states_pkey", + "schema": "public", + "table": "error_tick_states", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "issue_escalation_policies_pkey", + "schema": "public", + "table": "issue_escalation_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "issue_escalations_pkey", + "schema": "public", + "table": "issue_escalations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "investigations_pkey", + "schema": "public", + "table": "investigations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "live_activities_pkey", + "schema": "public", + "table": "live_activities", + "entityType": "pks" + }, + { + "columns": [ + "state" + ], + "nameExplicit": false, + "name": "oauth_auth_states_pkey", + "schema": "public", + "table": "oauth_auth_states", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "oauth_connections_pkey", + "schema": "public", + "table": "oauth_connections", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_onboarding_state_pkey", + "schema": "public", + "table": "org_onboarding_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "org_ingest_attribute_mappings_pkey", + "schema": "public", + "table": "org_ingest_attribute_mappings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "org_recommendation_issues_pkey", + "schema": "public", + "table": "org_recommendation_issues", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_ingest_sampling_policies_pkey", + "schema": "public", + "table": "org_ingest_sampling_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_connections_pkey", + "schema": "public", + "table": "planetscale_connections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_databases_pkey", + "schema": "public", + "table": "planetscale_databases", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_events_pkey", + "schema": "public", + "table": "planetscale_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_poll_state_pkey", + "schema": "public", + "table": "planetscale_poll_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "scrape_target_checks_pkey", + "schema": "public", + "table": "scrape_target_checks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "scrape_targets_pkey", + "schema": "public", + "table": "scrape_targets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "slack_workspaces_pkey", + "schema": "public", + "table": "slack_workspaces", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pr_reviews_pkey", + "schema": "public", + "table": "pr_reviews", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_commits_pkey", + "schema": "public", + "table": "vcs_commits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_installations_pkey", + "schema": "public", + "table": "vcs_installations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_repositories_pkey", + "schema": "public", + "table": "vcs_repositories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_repository_branches_pkey", + "schema": "public", + "table": "vcs_repository_branches", + "entityType": "pks" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/db/src/schema/chat-workspaces.ts b/packages/db/src/schema/chat-workspaces.ts index 21f39dd024..9c95bb1f82 100644 --- a/packages/db/src/schema/chat-workspaces.ts +++ b/packages/db/src/schema/chat-workspaces.ts @@ -8,11 +8,6 @@ import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm // // Deliberately absent, and what it would take to add each back: // -// - no credential columns. The connectors Maple ships authenticate with one -// global bot credential the host supplies, so there is nothing per-workspace -// to keep. A connector that mints a per-workspace token adds an encrypted -// column then, following the encrypted-secret pattern the other workspace -// integration in `packages/backend/src/services/integrations/` uses. // - no `revoked_at` / `revoked_reason`. Unlinking deletes the row; nothing // reads the history, and a deleted row cannot resolve a bot event. // - no `installed_by_user_id`. Nothing reads it — the install is already @@ -40,6 +35,16 @@ export const chatWorkspaces = pgTable( name: text("name").notNull(), /** Connector-defined settings, validated by the connector's own schema. */ settings: jsonb("settings").$type>().notNull(), + // The secret the connector's own install minted for THIS workspace, as one + // AES-256-GCM envelope over a string only that connector reads back. Null + // for a connector that authenticates with one deployment-wide credential — + // which is why this is three nullable columns rather than a second table. + // The AAD binds the envelope to `(org_id, connector, external_workspace_id)` + // (`chat-workspace-credentials.ts`), so a row's ciphertext cannot be moved + // onto another org's row by anyone holding only database write access. + credentialsCiphertext: text("credentials_ciphertext"), + credentialsIv: text("credentials_iv"), + credentialsTag: text("credentials_tag"), createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), }, (table) => [ diff --git a/packages/infra/src/cloudflare/stage.test.ts b/packages/infra/src/cloudflare/stage.test.ts index 6c3b3b76b6..4e2d168805 100644 --- a/packages/infra/src/cloudflare/stage.test.ts +++ b/packages/infra/src/cloudflare/stage.test.ts @@ -88,6 +88,7 @@ describe("resolveMapleDomains", () => { ingest: "ingest.eu.maple.dev", sync: "sync.eu.maple.dev", electric: "electric.eu.maple.dev", + chat: "chat.eu.maple.dev", }) expect(eu.landing).toBeUndefined() expect(eu.local).toBeUndefined() @@ -98,6 +99,13 @@ describe("resolveMapleDomains", () => { it("keeps the us production hostnames exactly as they were", () => { expect(resolveMapleDomains(stage("prd"))).toEqual(resolveMapleDomains(stage("prd"), "us")) expect(resolveMapleDomains(stage("prd")).web).toBe("app.maple.dev") + // A webhook connector's request URL is configured inside a chat platform's own app, so it + // has to be a hostname that does not change with a deploy. + expect(resolveMapleDomains(stage("prd")).chat).toBe("chat.maple.dev") + }) + + it("gives a PR preview no chat hostname", () => { + expect(resolveMapleDomains(stage("pr-12")).chat).toBeUndefined() }) it("has no eu hostnames for a PR preview", () => { diff --git a/packages/infra/src/cloudflare/stage.ts b/packages/infra/src/cloudflare/stage.ts index 6ebb4a4288..bfc6093527 100644 --- a/packages/infra/src/cloudflare/stage.ts +++ b/packages/infra/src/cloudflare/stage.ts @@ -38,6 +38,18 @@ export interface MapleDomains { electric?: string /** Auto-updating local-mode dashboard SPA (the `maple` binary points users here by default). */ local?: string + /** + * The chat-bot Worker (`apps/chat-bot`). + * + * It exists for exactly one reason: a chat platform that delivers its events as signed HTTP + * requests needs a URL to deliver them TO, and that URL is configured once inside a vendor's + * app and re-approved by every workspace when it changes. So it is a stable custom domain + * rather than a `workers.dev` URL, which carries the Cloudflare account subdomain. + * + * Only production instances get one. A dev stage reaches the same route through portless, and + * a PR preview has no chat app of its own to point at it. + */ + chat?: string } /** @@ -92,6 +104,7 @@ const PRD_DOMAINS: MapleDomains = { electric: "electric.maple.dev", landing: "maple.dev", local: "local.maple.dev", + chat: "chat.maple.dev", } /** @@ -106,6 +119,7 @@ const PRD_DOMAINS_EU: MapleDomains = { ingest: "ingest.eu.maple.dev", sync: "sync.eu.maple.dev", electric: "electric.eu.maple.dev", + chat: "chat.eu.maple.dev", } export function parseMapleStage(stage: string): MapleStage {