425 lines · 19.3 KB · TypeScript
RawDownload
import { column, idColumn, table, type AnyColumn, type AnyTable } from "@executor-js/fumadb/schema";import type { Condition, ConditionBuilder } from "@executor-js/fumadb/query"; import { StorageError, type FumaRow } from "./fuma-runtime";import {  assertOwnerPatch,  assertOwnerWritable,  assertReachReadOnly,  executorOwnerPolicyName,  executorTenantPolicyName,  executorUnscopedPolicyName,  ownerVisibilityCondition,  type ExecutorOwnerPolicyContext,} from "./owner-policy"; type UserColumns = Record<string, AnyColumn>;type AnyConditionBuilder = ConditionBuilder<Record<string, AnyColumn>>; // Column helpers. Index-participating columns use `varchar(255)` so unique// indexes stay portable (TEXT can't be indexed without a prefix length on// MySQL); free-form columns use `string` (TEXT).export const textColumn = (name: string) => column(name, "string");export const nullableTextColumn = (name: string) => column(name, "string").nullable();export const keyColumn = (name: string) => column(name, "varchar(255)");export const nullableKeyColumn = (name: string) => column(name, "varchar(255)").nullable();export const boolColumn = (name: string, defaultValue: boolean) =>  column(name, "bool").defaultTo(defaultValue);export const bigintColumn = (name: string) => column(name, "bigint");export const nullableBigintColumn = (name: string) => column(name, "bigint").nullable();export const jsonColumn = (name: string) => column(name, "json");export const nullableJsonColumn = (name: string) => column(name, "json").nullable();export const dateColumn = (name: string) => column(name, "timestamp"); // The policy callback hands us a `ConditionBuilder` typed to the specific table's// columns; it isn't assignable to the generic `Record<string, AnyColumn>` builder// (column-name positions are contravariant), so accept it loosely and re-narrow.const ownerVisibility = (builder: unknown, context: ExecutorOwnerPolicyContext) =>  ownerVisibilityCondition(builder as AnyConditionBuilder, context) as Condition | boolean; /** A truly global table (the blob store). Isolation is carried in the row's *  `namespace` (which encodes the owner partition + plugin id), not a policy. */const unscopedExecutorTable = <const TColumns extends UserColumns>(  name: string,  columns: TColumns,) => {  const out = table(name, {    ...columns,    row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"),    id: keyColumn("id"),  });  out.unique(`${name}_id_uidx`, ["id"]);  return out.policy({ name: executorUnscopedPolicyName });}; /** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */const tenantExecutorTable = <const TColumns extends UserColumns>(  name: string,  columns: TColumns,  uniqueKey: readonly string[],) => {  const out = table(name, {    ...columns,    row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"),    tenant: keyColumn("tenant"),  });  out.unique(`${name}_uidx`, [...uniqueKey]);  return out.policy<ExecutorOwnerPolicyContext>({    name: executorTenantPolicyName,    onRead: ({ builder, context }) => builder("tenant", "=", context.tenant),    onCreate: ({ values, context }) => {      // Tenant-scoped reads are already tenant-wide, so reach doesn't widen      // them — but the platform view must stay read-only on EVERY table it can      // reach, and `subject` is one of these.      assertReachReadOnly(name, "write", context);      if (values.tenant !== context.tenant) {        // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB table policy callbacks are promise callbacks, not Effect effects        throw new StorageError({          message: `Storage write on table "${name}" is outside the executor tenant.`,          cause: undefined,        });      }    },    onUpdate: ({ builder, context }) => {      assertReachReadOnly(name, "write", context);      return builder("tenant", "=", context.tenant);    },    onDelete: ({ builder, context }) => {      assertReachReadOnly(name, "delete", context);      return builder("tenant", "=", context.tenant);    },  });}; /** An owner-scoped table — partitioned by `(tenant, owner, subject)`, guarded by *  the executor owner policy. `uniqueKey` must include those three columns. */const ownedExecutorTable = <const TColumns extends UserColumns>(  name: string,  columns: TColumns,  uniqueKey: readonly string[],) => {  const out = table(name, {    ...columns,    row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"),    tenant: keyColumn("tenant"),    owner: keyColumn("owner"),    subject: keyColumn("subject"),  });  out.unique(`${name}_uidx`, [...uniqueKey]);  return out.policy<ExecutorOwnerPolicyContext>({    name: executorOwnerPolicyName,    onRead: ({ builder, context }) => ownerVisibility(builder, context),    onCreate: ({ values, context }) => assertOwnerWritable(name, values, context),    onUpdate: ({ builder, set, create, context }) => {      assertOwnerPatch(name, set, context);      assertOwnerPatch(name, create, context);      return ownerVisibility(builder, context);    },    // A delete carries no values to assert against, so the reach guard is the    // ONLY thing standing between a widened (platform-view) context and a    // tenant-wide delete — `ownerVisibility` would happily match every row.    onDelete: ({ builder, context }) => {      assertReachReadOnly(name, "delete", context);      return ownerVisibility(builder, context);    },  });}; const defineTables = <const TTables extends Record<string, AnyTable>>(tables: TTables): TTables =>  tables; export const coreTables = defineTables({  // The catalog — tenant-shared integration definitions. `config` is the owning  // plugin's opaque blob (openapi auth templates + spec; mcp url). Core never  // parses it.  integration: tenantExecutorTable(    "integration",    {      slug: keyColumn("slug"),      plugin_id: textColumn("plugin_id"),      // Display name. The pre-split field: `description` used to hold the      // name, so cloud backfills `name` from it (migration 0006) and other      // hosts fall back at read time (see rowToIntegration). Nullable because      // SQLite boot-ensure hosts cannot add a NOT NULL column to existing      // tables, so the column stays nullable even though it is always present      // in practice.      name: nullableTextColumn("name"),      // Actual prose description, now distinct from the name. Nullable: absent      // until a user/spec supplies one (cloud clears the old duplicated title      // to NULL in 0006).      description: nullableTextColumn("description"),      config: nullableJsonColumn("config"),      // The declared health check (HealthCheckSpec JSON): which authenticated      // operation a connection runs to prove its credential is alive and whose      // account it is. CORE-owned, deliberately NOT inside `config`, so no      // plugin's config decode/re-encode cycle can silently strip it and no      // plugin schema has to declare it. Null = no check declared.      health_check: nullableJsonColumn("health_check"),      // Epoch ms of the last tool-affecting config change (spec update, auth      // template edit). Compared against each connection's `tools_synced_at`      // so OTHER subjects' connections — whose tool rows the updater cannot      // write under the owner policy — lazily rebuild on their next read.      config_revised_at: nullableBigintColumn("config_revised_at"),      can_remove: boolColumn("can_remove", true),      can_refresh: boolColumn("can_refresh", false),      created_at: dateColumn("created_at"),      updated_at: dateColumn("updated_at"),    },    ["tenant", "slug"],  ),   // The join between a host's identity system (WorkOS accounts, Better Auth  // members, the local single-user sentinel) and the `subject` partition key  // smeared across the owned tables. NOT an identity system of its own: the  // hosts stay authoritative for names, emails, and membership. This table  // only records that a principal has been seen under this tenant, so a user  // who has no connection row is still answerable.  //  // Tenant-scoped, not owner-scoped, on purpose: any executor bound to the  // tenant may read it, which is what an operator-level (cross-subject) view  // needs, and it costs zero policy changes.  subject: tenantExecutorTable(    "subject",    {      // The host-auth principal id (cloud: the WorkOS accountId). Opaque here      // — it also carries host sentinels like "local", so nothing may parse it.      external_id: keyColumn("external_id"),      created_at: dateColumn("created_at"),      // Epoch ms of the last sighting on the request path. Nullable so a row      // can be created (e.g. at connection-create) before any sighting is      // recorded; bigint rather than a timestamp to match the other      // "last X happened at" columns here (`tools_synced_at`, `expires_at`).      last_seen_at: nullableBigintColumn("last_seen_at"),      // Subject lifecycle. Left as an unconstrained nullable string until the      // lifecycle values are actually defined; null means "no state recorded".      status: nullableTextColumn("status"),    },    ["tenant", "external_id"],  ),   // THE saved credential, one per (owner, integration, name). Resolves each named  // input via `provider` + the `item_ids` map (variable → provider item id). A  // single-secret connection is `{ "token": <id> }`; an apiKey method with two  // distinct inputs (e.g. Datadog) carries one entry per variable. All of a  // connection's inputs share the one `provider`. OAuth fields null for static.  connection: ownedExecutorTable(    "connection",    {      integration: keyColumn("integration"),      name: keyColumn("name"),      template: textColumn("template"),      provider: textColumn("provider"),      item_ids: jsonColumn("item_ids"),      identity_label: nullableTextColumn("identity_label"),      // User-curated, agent-visible "what is this connection for". Settable at      // create, editable after; never reset by OAuth re-mints.      description: nullableTextColumn("description"),      // Last health-check outcome (HealthCheckResult JSON: status, httpStatus,      // checkedAt, identity, detail). Written by every checkHealth run so the      // accounts list shows alive/expired AT A GLANCE (the customer ask)      // instead of only after a per-row manual probe. Null = never checked.      last_health: nullableJsonColumn("last_health"),      // Epoch ms of the last tool (re)production for this connection. Stale      // vs the integration's `config_revised_at` → re-produced on next read.      tools_synced_at: nullableBigintColumn("tools_synced_at"),      oauth_client: nullableTextColumn("oauth_client"),      // The OWNER of `oauth_client` (a Personal connection may be minted through      // a shared Workspace app), set together with `oauth_client`; null for      // static creds. Stored so every deref (refresh/complete/reconnect) reads it      // verbatim instead of re-deriving it via a sharing rule.      oauth_client_owner: nullableTextColumn("oauth_client_owner"),      refresh_item_id: nullableTextColumn("refresh_item_id"),      expires_at: nullableBigintColumn("expires_at"),      oauth_scope: nullableTextColumn("oauth_scope"),      // Per-connection token endpoint override. Set only when the code was      // redeemed at a region other than the oauth_client's configured token host      // (multi-site providers like Datadog signal the org's region on the      // callback). Null means refresh uses the oauth_client's `token_url`.      oauth_token_url: nullableTextColumn("oauth_token_url"),      provider_state: nullableJsonColumn("provider_state"),      created_at: dateColumn("created_at"),      updated_at: dateColumn("updated_at"),    },    ["tenant", "owner", "subject", "integration", "name"],  ),   // A registered OAuth app — owner-scoped (shared org app or a member's BYO app).  // A registered OAuth app — pure app identity (id/secret + endpoints). It carries  // NO scopes: what to request is the integration's concern, so the same app can  // back any integration. The granted scope is recorded per-connection  // (`connection.oauth_scope`).  oauth_client: ownedExecutorTable(    "oauth_client",    {      slug: keyColumn("slug"),      authorization_url: textColumn("authorization_url"),      token_url: textColumn("token_url"),      grant: textColumn("grant"),      client_id: textColumn("client_id"),      // The client secret is NOT stored inline — it's a provider `item_id` that      // resolves to the value via the default writable credential provider      // (WorkOS Vault on cloud, the local store on desktop). Null for public /      // PKCE clients (no secret). Keeps secrets out of plaintext columns.      client_secret_item_id: nullableTextColumn("client_secret_item_id"),      // RFC 8707 Resource Indicator (MCP). Sent on the refresh request so the      // re-minted access token stays bound to the same resource. Null when the      // provider doesn't use resource indicators.      resource: nullableTextColumn("resource"),      // Where this oauth_client came from. Null in old databases is treated as      // "manual" by the service layer.      origin_kind: nullableTextColumn("origin_kind"),      origin_integration: nullableTextColumn("origin_integration"),      // Authorization-server issuer that owns a DCR client, keying per-AS reuse.      // For a NEW DCR registration this is the DISCOVERED OIDC issuer (real      // information from the AS metadata, which can legitimately differ from what      // token_url would suggest). For a BACKFILLED legacy row it is instead a      // derived registrable-origin of token_url (a cache of the pure      // `registrableOriginOfUrl`, since no discovered issuer was ever recorded).      // Null for manual clients and legacy rows not yet backfilled.      origin_issuer: nullableTextColumn("origin_issuer"),      // The redirect URI a DCR client registered with the authorization server      // (RFC 7591 `redirect_uris`, always the single flow callback here).      // Strict servers reject an authorize request whose redirect_uri differs      // from the registration, so DCR reuse compares this against the current      // flow callback and re-registers on mismatch. Null for manual clients and      // for DCR rows written before this column existed — treated as matching,      // since re-registering every legacy client on upgrade would churn      // providers for the majority whose callback never changed.      origin_redirect_uri: nullableTextColumn("origin_redirect_uri"),      created_at: dateColumn("created_at"),    },    ["tenant", "owner", "subject", "slug"],  ),   // In-flight OAuth authorization-code flow, keyed by the minted `state`.  oauth_session: ownedExecutorTable(    "oauth_session",    {      state: keyColumn("state"),      client_slug: textColumn("client_slug"),      integration: textColumn("integration"),      name: textColumn("name"),      template: textColumn("template"),      redirect_url: textColumn("redirect_url"),      pkce_verifier: nullableTextColumn("pkce_verifier"),      identity_label: nullableTextColumn("identity_label"),      payload: jsonColumn("payload"),      expires_at: bigintColumn("expires_at"),      created_at: dateColumn("created_at"),    },    ["tenant", "state"],  ),   // Persisted, per-connection tools (option C). Address is derived from  // (integration, owner, connection, name).  tool: ownedExecutorTable(    "tool",    {      integration: keyColumn("integration"),      connection: keyColumn("connection"),      plugin_id: textColumn("plugin_id"),      name: keyColumn("name"),      description: textColumn("description"),      input_schema: nullableJsonColumn("input_schema"),      output_schema: nullableJsonColumn("output_schema"),      annotations: nullableJsonColumn("annotations"),      created_at: dateColumn("created_at"),      updated_at: dateColumn("updated_at"),    },    ["tenant", "owner", "subject", "integration", "connection", "name"],  ),   // Shared JSON-schema $defs, per-connection (mirrors `tool`).  definition: ownedExecutorTable(    "definition",    {      integration: keyColumn("integration"),      connection: keyColumn("connection"),      plugin_id: textColumn("plugin_id"),      name: keyColumn("name"),      schema: jsonColumn("schema"),      created_at: dateColumn("created_at"),    },    ["tenant", "owner", "subject", "integration", "connection", "name"],  ),   // User-authored tool policies (approve / require_approval / block).  tool_policy: ownedExecutorTable(    "tool_policy",    {      id: keyColumn("id"),      pattern: textColumn("pattern"),      action: textColumn("action"),      position: textColumn("position"),      created_at: dateColumn("created_at"),      updated_at: dateColumn("updated_at"),    },    ["tenant", "owner", "subject", "id"],  ),   // Host-owned plugin storage (shared `plugin_storage` table, owner-scoped).  plugin_storage: ownedExecutorTable(    "plugin_storage",    {      plugin_id: keyColumn("plugin_id"),      collection: keyColumn("collection"),      key: keyColumn("key"),      data: jsonColumn("data"),      created_at: dateColumn("created_at"),      updated_at: dateColumn("updated_at"),    },    ["tenant", "owner", "subject", "plugin_id", "collection", "key"],  ),   // Opaque blob store, global. Isolation is carried in `namespace` (which  // encodes the owner partition + plugin id), so this table is unscoped.  blob: unscopedExecutorTable("blob", {    namespace: keyColumn("namespace"),    key: keyColumn("key"),    value: textColumn("value"),  }),}); export const coreSchema = coreTables;export type CoreSchema = typeof coreTables; export type IntegrationRow = FumaRow<CoreSchema["integration"]>;export type SubjectRow = FumaRow<CoreSchema["subject"]>;export type ConnectionRow = FumaRow<CoreSchema["connection"]>;export type OAuthClientRow = FumaRow<CoreSchema["oauth_client"]>;export type OAuthSessionRow = FumaRow<CoreSchema["oauth_session"]>;export type ToolRow = FumaRow<CoreSchema["tool"]>;/** The tool-row projection the invoke/list hot paths load: everything except *  the heavy `input_schema`/`output_schema` JSON, which only `tools.schema` *  (describe) needs. Plugin `invokeTool` receives this shape — operation *  details ride in plugin storage or `annotations`, not the row schemas. */export type ToolInvocationRow = Omit<ToolRow, "input_schema" | "output_schema">;/** The columns backing {@link ToolInvocationRow}, for `select` projections. */export const TOOL_INVOCATION_COLUMNS = [  "tenant",  "owner",  "subject",  "integration",  "connection",  "plugin_id",  "name",  "description",  "annotations",  "created_at",  "updated_at",] as const satisfies readonly (keyof ToolRow)[];export type DefinitionRow = FumaRow<CoreSchema["definition"]>;export type ToolPolicyRow = FumaRow<CoreSchema["tool_policy"]>;export type PluginStorageRow = FumaRow<CoreSchema["plugin_storage"]>;export type BlobRow = FumaRow<CoreSchema["blob"]>; export type ToolPolicyAction = "approve" | "require_approval" | "block"; export const TOOL_POLICY_ACTIONS = [  "approve",  "require_approval",  "block",] as const satisfies readonly ToolPolicyAction[]; export const isToolPolicyAction = (value: unknown): value is ToolPolicyAction =>  typeof value === "string" && (TOOL_POLICY_ACTIONS as readonly string[]).includes(value);