feat(mcp) [split 2/8]: add governed access contracts (#9557)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 2/8 and focuses on database schema and shared governance contracts > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: The governed access model needs additive persistence and synchronized shared types before server enforcement can compile. - Proposed solution: Adds migrations 0148–0169, tool-access and Smoke Lab schema, shared types/validators/gallery helpers, and the minimal compile-required contract consumers identified by boundary testing. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/01-demo-servers`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: QA for migrations/validators; Greptile on every PR. ## What Changed - Adds migrations 0148–0169, tool-access and Smoke Lab schema, shared types/validators/gallery helpers, and the minimal compile-required contract consumers identified by boundary testing. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` — passed, including migration numbering and safety checks - `pnpm --filter @paperclipai/db test` — passed - `pnpm --filter @paperclipai/shared test` — passed ## Risks - Migration or contract mistakes could affect every upper layer; all migrations are additive/idempotent and compile consumers are included in this boundary. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
7b35de65aa
commit
1de0a3bb1e
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
|
||||
|
|
@ -410,6 +411,79 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
60_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"restores fallback COPY data when child tables are dumped before parent tables",
|
||||
async () => {
|
||||
const sourceConnectionString = await createTempDatabase();
|
||||
const restoreConnectionString = await createSiblingDatabase(
|
||||
sourceConnectionString,
|
||||
"paperclip_copy_fk_restore_target",
|
||||
);
|
||||
const backupDir = createTempDir("paperclip-db-copy-fk-backup-");
|
||||
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
|
||||
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
||||
const originalPgDumpPath = process.env.PAPERCLIP_PG_DUMP_PATH;
|
||||
process.env.PAPERCLIP_PG_DUMP_PATH = "/bin/false";
|
||||
|
||||
try {
|
||||
await sourceSql.unsafe(`
|
||||
CREATE TABLE "public"."zzz_parent_records" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"name" text NOT NULL
|
||||
);
|
||||
CREATE TABLE "public"."aaa_child_records" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"parent_id" uuid NOT NULL REFERENCES "public"."zzz_parent_records"("id") ON DELETE CASCADE,
|
||||
"note" text NOT NULL
|
||||
);
|
||||
INSERT INTO "public"."zzz_parent_records" ("id", "name")
|
||||
VALUES ('11111111-1111-4111-8111-111111111111', 'parent');
|
||||
INSERT INTO "public"."aaa_child_records" ("id", "parent_id", "note")
|
||||
VALUES (
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'child emitted before parent'
|
||||
);
|
||||
`);
|
||||
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir,
|
||||
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
filenamePrefix: "paperclip-copy-fk-test",
|
||||
backupEngine: "auto",
|
||||
});
|
||||
|
||||
const backupSql = gunzipSync(await fs.promises.readFile(result.backupFile)).toString("utf8");
|
||||
expect(backupSql.indexOf("-- Data for: public.aaa_child_records")).toBeGreaterThan(-1);
|
||||
expect(backupSql.indexOf("-- Data for: public.aaa_child_records")).toBeLessThan(
|
||||
backupSql.indexOf("-- Data for: public.zzz_parent_records"),
|
||||
);
|
||||
|
||||
await runDatabaseRestore({
|
||||
connectionString: restoreConnectionString,
|
||||
backupFile: result.backupFile,
|
||||
});
|
||||
|
||||
const rows = await restoreSql.unsafe<{ note: string; name: string }[]>(`
|
||||
SELECT child."note", parent."name"
|
||||
FROM "public"."aaa_child_records" child
|
||||
JOIN "public"."zzz_parent_records" parent ON parent."id" = child."parent_id"
|
||||
`);
|
||||
expect(rows).toEqual([{ note: "child emitted before parent", name: "parent" }]);
|
||||
} finally {
|
||||
if (originalPgDumpPath === undefined) {
|
||||
delete process.env.PAPERCLIP_PG_DUMP_PATH;
|
||||
} else {
|
||||
process.env.PAPERCLIP_PG_DUMP_PATH = originalPgDumpPath;
|
||||
}
|
||||
await sourceSql.end();
|
||||
await restoreSql.end();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"restores legacy public-only backups without migration history",
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -985,10 +985,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
|
||||
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
|
||||
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
|
||||
let psqlRestoreError: unknown = null;
|
||||
try {
|
||||
await restoreWithPsql(opts, connectTimeout);
|
||||
return;
|
||||
} catch (error) {
|
||||
psqlRestoreError = error;
|
||||
if (!(await hasStatementBreakpoints(opts.backupFile))) {
|
||||
throw new Error(
|
||||
`Failed to restore ${basename(opts.backupFile)} with psql: ${sanitizeRestoreErrorMessage(error)}`,
|
||||
|
|
@ -1010,8 +1012,9 @@ export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promi
|
|||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("--"))
|
||||
: null;
|
||||
const psqlMessage = psqlRestoreError === null ? "" : `; psql error: ${sanitizeRestoreErrorMessage(psqlRestoreError)}`;
|
||||
throw new Error(
|
||||
`Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`,
|
||||
`Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}${psqlMessage}`,
|
||||
);
|
||||
} finally {
|
||||
await sql.end();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import postgres from "postgres";
|
|||
import {
|
||||
applyPendingMigrations,
|
||||
inspectMigrations,
|
||||
resetPostgresDatabase,
|
||||
} from "./client.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -88,6 +89,34 @@ if (!embeddedPostgresSupport.supported) {
|
|||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("resetPostgresDatabase", () => {
|
||||
it("recreates an existing database so stale tables are removed", async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
const adminUrl = new URL(connectionString);
|
||||
const databaseName = adminUrl.pathname.replace(/^\//, "");
|
||||
adminUrl.pathname = "/postgres";
|
||||
|
||||
const setupSql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await setupSql.unsafe(`CREATE TABLE stale_reseed_target_only (id integer PRIMARY KEY)`);
|
||||
} finally {
|
||||
await setupSql.end();
|
||||
}
|
||||
|
||||
await resetPostgresDatabase(adminUrl.toString(), databaseName);
|
||||
|
||||
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const rows = await verifySql.unsafe<{ stale_table: string | null }[]>(
|
||||
`SELECT to_regclass('public.stale_reseed_target_only')::text AS stale_table`,
|
||||
);
|
||||
expect(rows[0]?.stale_table).toBeNull();
|
||||
} finally {
|
||||
await verifySql.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("applyPendingMigrations", () => {
|
||||
it("rejects unallowlisted migration backfills that bump updated_at on user-visible tables", async () => {
|
||||
const entries = await fs.promises.readdir(new URL("./migrations", import.meta.url), {
|
||||
|
|
|
|||
|
|
@ -776,4 +776,25 @@ export async function ensurePostgresDatabase(
|
|||
}
|
||||
}
|
||||
|
||||
export async function resetPostgresDatabase(
|
||||
url: string,
|
||||
databaseName: string,
|
||||
): Promise<"reset"> {
|
||||
const quotedDatabaseName = quoteIdentifier(databaseName);
|
||||
const sql = createUtilitySql(url);
|
||||
try {
|
||||
await sql`
|
||||
select pg_terminate_backend(pid)
|
||||
from pg_stat_activity
|
||||
where datname = ${databaseName}
|
||||
and pid <> pg_backend_pid()
|
||||
`;
|
||||
await sql.unsafe(`drop database if exists ${quotedDatabaseName}`);
|
||||
await sql.unsafe(`create database ${quotedDatabaseName} encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`);
|
||||
return "reset";
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof createDb>;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export {
|
|||
createDb,
|
||||
getPostgresDataDirectory,
|
||||
ensurePostgresDatabase,
|
||||
resetPostgresDatabase,
|
||||
inspectMigrations,
|
||||
applyPendingMigrations,
|
||||
reconcilePendingMigrationHistory,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_applications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_connections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"application_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"transport" text NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"credential_refs" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"health_status" text DEFAULT 'unchecked' NOT NULL,
|
||||
"health_message" text,
|
||||
"last_health_at" timestamp with time zone,
|
||||
"last_catalog_refresh_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_catalog_entries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"connection_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"title" text,
|
||||
"description" text,
|
||||
"input_schema" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"annotations" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"risk_level" text DEFAULT 'read' NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"version_hash" text NOT NULL,
|
||||
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"quarantined_at" timestamp with time zone,
|
||||
"quarantine_reason" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_runtime_slots" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"connection_id" uuid NOT NULL,
|
||||
"slot_key" text NOT NULL,
|
||||
"status" text DEFAULT 'stopped' NOT NULL,
|
||||
"provider_ref" text,
|
||||
"health_status" text DEFAULT 'unchecked' NOT NULL,
|
||||
"health_message" text,
|
||||
"last_started_at" timestamp with time zone,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"idle_deadline_at" timestamp with time zone,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_access_audit_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"connection_id" uuid,
|
||||
"catalog_entry_id" uuid,
|
||||
"actor_type" text DEFAULT 'system' NOT NULL,
|
||||
"actor_id" text,
|
||||
"action" text NOT NULL,
|
||||
"outcome" text NOT NULL,
|
||||
"reason_code" text,
|
||||
"details" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_applications_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_applications" ADD CONSTRAINT "tool_applications_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_application_id_tool_applications_id_fk') THEN
|
||||
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_connection_id_tool_connections_id_fk') THEN
|
||||
ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_connection_id_tool_connections_id_fk') THEN
|
||||
ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_connection_id_tool_connections_id_fk') THEN
|
||||
ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk') THEN
|
||||
ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "tool_applications_company_idx" ON "tool_applications" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_applications_company_name_uq" ON "tool_applications" USING btree ("company_id", "name");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_connections_company_idx" ON "tool_connections" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_connections_application_idx" ON "tool_connections" USING btree ("application_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_connections_company_name_uq" ON "tool_connections" USING btree ("company_id", "name");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_catalog_entries_company_idx" ON "tool_catalog_entries" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_catalog_entries_connection_idx" ON "tool_catalog_entries" USING btree ("connection_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_catalog_entries_connection_name_uq" ON "tool_catalog_entries" USING btree ("connection_id", "name");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_runtime_slots_company_idx" ON "tool_runtime_slots" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_runtime_slots_connection_idx" ON "tool_runtime_slots" USING btree ("connection_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_runtime_slots_slot_key_uq" ON "tool_runtime_slots" USING btree ("company_id", "slot_key");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_access_audit_company_created_idx" ON "tool_access_audit_events" USING btree ("company_id", "created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_access_audit_connection_idx" ON "tool_access_audit_events" USING btree ("connection_id");
|
||||
|
|
@ -0,0 +1,612 @@
|
|||
ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "application_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "description" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "plugin_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "owner_agent_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "owner_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_applications" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;--> statement-breakpoint
|
||||
|
||||
UPDATE "tool_applications"
|
||||
SET "application_key" = concat(
|
||||
coalesce(nullif(lower(regexp_replace("name", '[^a-zA-Z0-9._:-]+', '-', 'g')), ''), 'app'),
|
||||
'-',
|
||||
"id"::text
|
||||
)
|
||||
WHERE "application_key" IS NULL;--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "connection_kind" text DEFAULT 'managed' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "transport_config" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "credential_secret_refs" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "health_checked_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "last_error" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "created_by_agent_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "created_by_user_id" text;--> statement-breakpoint
|
||||
|
||||
UPDATE "tool_connections"
|
||||
SET "transport_config" = coalesce(nullif("config", '{}'::jsonb), '{}'::jsonb)
|
||||
WHERE "transport_config" = '{}'::jsonb
|
||||
AND "config" <> '{}'::jsonb;--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "application_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "entry_kind" text DEFAULT 'tool' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "tool_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "output_schema" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "is_read_only" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "is_write" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "is_destructive" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "version" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "schema_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "reviewed_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "reviewed_by_agent_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_catalog_entries" ADD COLUMN IF NOT EXISTS "reviewed_by_user_id" text;--> statement-breakpoint
|
||||
|
||||
UPDATE "tool_catalog_entries" AS e
|
||||
SET
|
||||
"application_id" = c."application_id",
|
||||
"tool_name" = coalesce(e."tool_name", e."name"),
|
||||
"is_read_only" = CASE WHEN e."risk_level" = 'read' THEN true ELSE false END,
|
||||
"is_write" = CASE WHEN e."risk_level" IN ('write', 'destructive') THEN true ELSE false END,
|
||||
"is_destructive" = CASE WHEN e."risk_level" = 'destructive' THEN true ELSE false END,
|
||||
"schema_hash" = coalesce(e."schema_hash", e."version_hash")
|
||||
FROM "tool_connections" c
|
||||
WHERE c."id" = e."connection_id";--> statement-breakpoint
|
||||
|
||||
UPDATE "tool_catalog_entries"
|
||||
SET "tool_name" = "name"
|
||||
WHERE "tool_name" IS NULL;--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "tool_catalog_entries" ALTER COLUMN "tool_name" SET NOT NULL;--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "application_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "project_workspace_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "execution_workspace_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "issue_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "owner_scope_type" text DEFAULT 'connection' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "owner_scope_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "runtime_kind" text DEFAULT 'local_stdio' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "reuse_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "workspace_scope" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "credential_scope_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "provider" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "process_id" integer;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "command_template_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "last_health_check_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "started_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "stopped_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "idle_expires_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ADD COLUMN IF NOT EXISTS "last_error" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_runtime_slots" ALTER COLUMN "connection_id" DROP NOT NULL;--> statement-breakpoint
|
||||
|
||||
UPDATE "tool_runtime_slots" AS s
|
||||
SET "application_id" = c."application_id"
|
||||
FROM "tool_connections" c
|
||||
WHERE c."id" = s."connection_id"
|
||||
AND s."application_id" IS NULL;--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_profiles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"profile_key" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"default_action" text DEFAULT 'deny' NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_profile_entries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"selector_type" text NOT NULL,
|
||||
"effect" text DEFAULT 'include' NOT NULL,
|
||||
"application_id" uuid,
|
||||
"connection_id" uuid,
|
||||
"catalog_entry_id" uuid,
|
||||
"tool_name" text,
|
||||
"risk_level" text,
|
||||
"conditions" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_profile_bindings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"target_type" text NOT NULL,
|
||||
"target_id" text NOT NULL,
|
||||
"priority" integer DEFAULT 100 NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_policies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"policy_type" text NOT NULL,
|
||||
"priority" integer DEFAULT 100 NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"selectors" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"conditions" jsonb,
|
||||
"config" jsonb,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_invocations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"idempotency_key" text,
|
||||
"actor_type" text DEFAULT 'system' NOT NULL,
|
||||
"actor_id" text,
|
||||
"agent_id" uuid,
|
||||
"issue_id" uuid,
|
||||
"run_id" uuid,
|
||||
"application_id" uuid,
|
||||
"connection_id" uuid,
|
||||
"catalog_entry_id" uuid,
|
||||
"tool_name" text NOT NULL,
|
||||
"arguments_hash" text,
|
||||
"arguments_summary" jsonb,
|
||||
"policy_decision" text,
|
||||
"matched_policy_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"approval_state" text DEFAULT 'not_required' NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"upstream_request_id" text,
|
||||
"result_hash" text,
|
||||
"result_summary" jsonb,
|
||||
"result_size_bytes" integer,
|
||||
"result_artifact_id" uuid,
|
||||
"error_code" text,
|
||||
"error_message" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_action_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"invocation_id" uuid NOT NULL,
|
||||
"issue_id" uuid,
|
||||
"interaction_id" uuid,
|
||||
"approval_id" uuid,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"canonical_arguments_hash" text NOT NULL,
|
||||
"canonical_arguments_summary" jsonb NOT NULL,
|
||||
"signed_arguments" text,
|
||||
"preview_markdown" text,
|
||||
"requested_by_agent_id" uuid,
|
||||
"requested_by_user_id" text,
|
||||
"resolved_by_agent_id" uuid,
|
||||
"resolved_by_user_id" text,
|
||||
"decided_by_agent_id" uuid,
|
||||
"decided_by_user_id" text,
|
||||
"decided_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_call_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"event_type" text NOT NULL,
|
||||
"actor_type" text DEFAULT 'system' NOT NULL,
|
||||
"actor_id" text,
|
||||
"agent_id" uuid,
|
||||
"run_id" uuid,
|
||||
"issue_id" uuid,
|
||||
"application_id" uuid,
|
||||
"connection_id" uuid,
|
||||
"catalog_entry_id" uuid,
|
||||
"invocation_id" uuid,
|
||||
"action_request_id" uuid,
|
||||
"runtime_slot_id" uuid,
|
||||
"tool_name" text,
|
||||
"decision" text,
|
||||
"matched_policy_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"reason_code" text,
|
||||
"outcome" text DEFAULT 'pending' NOT NULL,
|
||||
"latency_ms" integer,
|
||||
"arguments_summary" jsonb,
|
||||
"request_hash" text,
|
||||
"request_summary" jsonb,
|
||||
"result_hash" text,
|
||||
"result_summary" jsonb,
|
||||
"result_size_bytes" integer,
|
||||
"redaction_plan" jsonb,
|
||||
"rate_limit_state" jsonb,
|
||||
"metadata" jsonb,
|
||||
"error_code" text,
|
||||
"error_message" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tool_rate_limit_counters" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"policy_id" uuid NOT NULL,
|
||||
"counter_key" text NOT NULL,
|
||||
"scope_type" text NOT NULL,
|
||||
"scope_id" text NOT NULL,
|
||||
"window_kind" text NOT NULL,
|
||||
"window_start_at" timestamp with time zone NOT NULL,
|
||||
"limit" integer NOT NULL,
|
||||
"remaining" integer NOT NULL,
|
||||
"reset_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_applications_plugin_id_plugins_id_fk') THEN
|
||||
ALTER TABLE "tool_applications" ADD CONSTRAINT "tool_applications_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_applications_owner_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_applications" ADD CONSTRAINT "tool_applications_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_created_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_application_id_tool_applications_id_fk') THEN
|
||||
ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_catalog_entries_reviewed_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_catalog_entries" ADD CONSTRAINT "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk" FOREIGN KEY ("reviewed_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profiles_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_profiles" ADD CONSTRAINT "tool_profiles_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_profile_id_tool_profiles_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_profile_id_tool_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."tool_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_application_id_tool_applications_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_connection_id_tool_connections_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_entries" ADD CONSTRAINT "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_bindings_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_bindings" ADD CONSTRAINT "tool_profile_bindings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_bindings_profile_id_tool_profiles_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_bindings" ADD CONSTRAINT "tool_profile_bindings_profile_id_tool_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."tool_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_profile_bindings_created_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_profile_bindings" ADD CONSTRAINT "tool_profile_bindings_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_policies_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_policies" ADD CONSTRAINT "tool_policies_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_policies_created_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_policies" ADD CONSTRAINT "tool_policies_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_application_id_tool_applications_id_fk') THEN
|
||||
ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_project_workspace_id_project_workspaces_id_fk') THEN
|
||||
ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk') THEN
|
||||
ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_runtime_slots_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "tool_runtime_slots" ADD CONSTRAINT "tool_runtime_slots_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_run_id_heartbeat_runs_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_application_id_tool_applications_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_connection_id_tool_connections_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_invocation_id_tool_invocations_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_invocation_id_tool_invocations_id_fk" FOREIGN KEY ("invocation_id") REFERENCES "public"."tool_invocations"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_interaction_id_issue_thread_interactions_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_interaction_id_issue_thread_interactions_id_fk" FOREIGN KEY ("interaction_id") REFERENCES "public"."issue_thread_interactions"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_approval_id_approvals_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_approval_id_approvals_id_fk" FOREIGN KEY ("approval_id") REFERENCES "public"."approvals"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_requested_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_requested_by_agent_id_agents_id_fk" FOREIGN KEY ("requested_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_resolved_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_action_requests_decided_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_action_requests" ADD CONSTRAINT "tool_action_requests_decided_by_agent_id_agents_id_fk" FOREIGN KEY ("decided_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_run_id_heartbeat_runs_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_application_id_tool_applications_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_connection_id_tool_connections_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk" FOREIGN KEY ("catalog_entry_id") REFERENCES "public"."tool_catalog_entries"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_invocation_id_tool_invocations_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_invocation_id_tool_invocations_id_fk" FOREIGN KEY ("invocation_id") REFERENCES "public"."tool_invocations"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_action_request_id_tool_action_requests_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_action_request_id_tool_action_requests_id_fk" FOREIGN KEY ("action_request_id") REFERENCES "public"."tool_action_requests"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk" FOREIGN KEY ("runtime_slot_id") REFERENCES "public"."tool_runtime_slots"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_rate_limit_counters_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_rate_limit_counters" ADD CONSTRAINT "tool_rate_limit_counters_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_rate_limit_counters_policy_id_tool_policies_id_fk') THEN
|
||||
ALTER TABLE "tool_rate_limit_counters" ADD CONSTRAINT "tool_rate_limit_counters_policy_id_tool_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."tool_policies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "tool_applications_company_status_idx" ON "tool_applications" USING btree ("company_id", "status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_applications_company_key_uq" ON "tool_applications" USING btree ("company_id", "application_key");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_connections_company_enabled_idx" ON "tool_connections" USING btree ("company_id", "enabled");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_catalog_entries_application_idx" ON "tool_catalog_entries" USING btree ("application_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_catalog_entries_company_status_idx" ON "tool_catalog_entries" USING btree ("company_id", "status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_profiles_company_status_idx" ON "tool_profiles" USING btree ("company_id", "status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_profiles_company_key_uq" ON "tool_profiles" USING btree ("company_id", "profile_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_profiles_company_name_uq" ON "tool_profiles" USING btree ("company_id", "name");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_profile_entries_company_profile_idx" ON "tool_profile_entries" USING btree ("company_id", "profile_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_profile_entries_application_idx" ON "tool_profile_entries" USING btree ("company_id", "application_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_profile_entries_connection_idx" ON "tool_profile_entries" USING btree ("company_id", "connection_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_profile_entries_catalog_entry_idx" ON "tool_profile_entries" USING btree ("company_id", "catalog_entry_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_profile_bindings_company_target_idx" ON "tool_profile_bindings" USING btree ("company_id", "target_type", "target_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_profile_bindings_target_profile_uq" ON "tool_profile_bindings" USING btree ("company_id", "target_type", "target_id", "profile_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_policies_company_enabled_idx" ON "tool_policies" USING btree ("company_id", "enabled");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_policies_company_type_idx" ON "tool_policies" USING btree ("company_id", "policy_type");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_policies_company_name_uq" ON "tool_policies" USING btree ("company_id", "name");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_runtime_slots_execution_workspace_idx" ON "tool_runtime_slots" USING btree ("company_id", "execution_workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_invocations_company_created_idx" ON "tool_invocations" USING btree ("company_id", "created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_invocations_run_idx" ON "tool_invocations" USING btree ("company_id", "run_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_invocations_issue_idx" ON "tool_invocations" USING btree ("company_id", "issue_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_invocations_company_idempotency_uq" ON "tool_invocations" USING btree ("company_id", "idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_action_requests_company_status_idx" ON "tool_action_requests" USING btree ("company_id", "status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_action_requests_invocation_idx" ON "tool_action_requests" USING btree ("invocation_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_action_requests_issue_idx" ON "tool_action_requests" USING btree ("company_id", "issue_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_call_events_company_created_idx" ON "tool_call_events" USING btree ("company_id", "created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_call_events_run_idx" ON "tool_call_events" USING btree ("company_id", "run_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_call_events_issue_idx" ON "tool_call_events" USING btree ("company_id", "issue_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_call_events_invocation_idx" ON "tool_call_events" USING btree ("invocation_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_rate_limit_counters_company_idx" ON "tool_rate_limit_counters" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_rate_limit_counters_window_uq" ON "tool_rate_limit_counters" USING btree ("company_id", "policy_id", "counter_key", "window_kind", "window_start_at");--> statement-breakpoint
|
||||
|
||||
INSERT INTO "tool_applications" (
|
||||
"company_id",
|
||||
"application_key",
|
||||
"name",
|
||||
"type",
|
||||
"status",
|
||||
"plugin_id",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
c."id",
|
||||
'paperclip_plugin:' || p."plugin_key",
|
||||
coalesce(p."manifest_json"->>'name', p."plugin_key"),
|
||||
'paperclip_plugin',
|
||||
'active',
|
||||
p."id",
|
||||
jsonb_build_object('source', 'plugin_backfill', 'pluginKey', p."plugin_key"),
|
||||
now(),
|
||||
now()
|
||||
FROM "companies" c
|
||||
CROSS JOIN "plugins" p
|
||||
WHERE jsonb_array_length(coalesce(p."manifest_json"->'tools', '[]'::jsonb)) > 0
|
||||
ON CONFLICT ("company_id", "name") DO NOTHING;--> statement-breakpoint
|
||||
|
||||
INSERT INTO "tool_connections" (
|
||||
"company_id",
|
||||
"application_id",
|
||||
"name",
|
||||
"connection_kind",
|
||||
"transport",
|
||||
"status",
|
||||
"enabled",
|
||||
"config",
|
||||
"transport_config",
|
||||
"credential_refs",
|
||||
"credential_secret_refs",
|
||||
"health_status",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
a."company_id",
|
||||
a."id",
|
||||
'Plugin: ' || coalesce(p."manifest_json"->>'name', p."plugin_key"),
|
||||
'managed',
|
||||
'remote_http',
|
||||
'active',
|
||||
true,
|
||||
jsonb_build_object('pluginKey', p."plugin_key", 'type', 'paperclip_plugin'),
|
||||
jsonb_build_object('pluginKey', p."plugin_key", 'type', 'paperclip_plugin'),
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'ok',
|
||||
now(),
|
||||
now()
|
||||
FROM "tool_applications" a
|
||||
JOIN "plugins" p ON p."id" = a."plugin_id"
|
||||
WHERE a."type" = 'paperclip_plugin'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "tool_connections" existing
|
||||
WHERE existing."company_id" = a."company_id"
|
||||
AND existing."application_id" = a."id"
|
||||
)
|
||||
ON CONFLICT ("company_id", "name") DO NOTHING;--> statement-breakpoint
|
||||
|
||||
INSERT INTO "tool_catalog_entries" (
|
||||
"company_id",
|
||||
"application_id",
|
||||
"connection_id",
|
||||
"entry_kind",
|
||||
"name",
|
||||
"tool_name",
|
||||
"title",
|
||||
"description",
|
||||
"input_schema",
|
||||
"annotations",
|
||||
"risk_level",
|
||||
"is_read_only",
|
||||
"is_write",
|
||||
"is_destructive",
|
||||
"status",
|
||||
"version_hash",
|
||||
"schema_hash",
|
||||
"first_seen_at",
|
||||
"last_seen_at",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
c."company_id",
|
||||
c."application_id",
|
||||
c."id",
|
||||
'tool',
|
||||
tool.value->>'name',
|
||||
tool.value->>'name',
|
||||
coalesce(tool.value->>'displayName', tool.value->>'title'),
|
||||
tool.value->>'description',
|
||||
coalesce(tool.value->'parametersSchema', '{}'::jsonb),
|
||||
'{}'::jsonb,
|
||||
'read',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'active',
|
||||
md5(tool.value::text),
|
||||
md5(tool.value::text),
|
||||
now(),
|
||||
now(),
|
||||
now(),
|
||||
now()
|
||||
FROM "tool_connections" c
|
||||
JOIN "tool_applications" a ON a."id" = c."application_id"
|
||||
JOIN "plugins" p ON p."id" = a."plugin_id"
|
||||
CROSS JOIN LATERAL jsonb_array_elements(coalesce(p."manifest_json"->'tools', '[]'::jsonb)) AS tool(value)
|
||||
WHERE a."type" = 'paperclip_plugin'
|
||||
AND tool.value ? 'name'
|
||||
ON CONFLICT ("connection_id", "name") DO NOTHING;--> statement-breakpoint
|
||||
|
||||
INSERT INTO "principal_permission_grants" (
|
||||
"company_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission_key",
|
||||
"scope",
|
||||
"granted_by_user_id",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
memberships."company_id",
|
||||
memberships."principal_type",
|
||||
memberships."principal_id",
|
||||
permissions."permission_key",
|
||||
NULL,
|
||||
NULL,
|
||||
now(),
|
||||
now()
|
||||
FROM "company_memberships" memberships
|
||||
JOIN (
|
||||
VALUES
|
||||
('tools:admin'),
|
||||
('tools:manage_connections'),
|
||||
('tools:manage_profiles'),
|
||||
('tools:view_audit'),
|
||||
('tools:manage_runtime')
|
||||
) AS permissions("permission_key") ON true
|
||||
WHERE memberships."principal_type" = 'user'
|
||||
AND memberships."status" = 'active'
|
||||
AND memberships."membership_role" IN ('owner', 'admin')
|
||||
ON CONFLICT ("company_id", "principal_type", "principal_id", "permission_key") DO NOTHING;--> statement-breakpoint
|
||||
|
||||
INSERT INTO "principal_permission_grants" (
|
||||
"company_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission_key",
|
||||
"scope",
|
||||
"granted_by_user_id",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
agents."company_id",
|
||||
'agent',
|
||||
agents."id",
|
||||
permissions."permission_key",
|
||||
NULL,
|
||||
NULL,
|
||||
now(),
|
||||
now()
|
||||
FROM "agents"
|
||||
JOIN (
|
||||
VALUES
|
||||
('tools:admin'),
|
||||
('tools:manage_connections'),
|
||||
('tools:manage_profiles'),
|
||||
('tools:view_audit'),
|
||||
('tools:manage_runtime')
|
||||
) AS permissions("permission_key") ON true
|
||||
WHERE agents."role" IN ('ceo', 'cto')
|
||||
AND agents."status" NOT IN ('pending_approval', 'terminated')
|
||||
ON CONFLICT ("company_id", "principal_type", "principal_id", "permission_key") DO NOTHING;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "catalog_version_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "catalog_schema_hash" text;--> statement-breakpoint
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_gateway_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"issue_id" uuid,
|
||||
"project_id" uuid,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_run_id_heartbeat_runs_id_fk') THEN
|
||||
ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_project_id_projects_id_fk') THEN
|
||||
ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_gateway_sessions_token_hash_uq" ON "tool_gateway_sessions" USING btree ("token_hash");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_company_agent_idx" ON "tool_gateway_sessions" USING btree ("company_id", "agent_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_company_expires_idx" ON "tool_gateway_sessions" USING btree ("company_id", "expires_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_run_idx" ON "tool_gateway_sessions" USING btree ("company_id", "run_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_issue_idx" ON "tool_gateway_sessions" USING btree ("company_id", "issue_id");
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
-- Harden DELETE /tool-applications/:applicationId against concurrent connection creation.
|
||||
-- The tool_connections.application_id FK was ON DELETE CASCADE, so a connection created in
|
||||
-- the gap between the endpoint's "any connections?" pre-check and its DELETE could be silently
|
||||
-- removed by the cascade instead of forcing the promised 409. Switch the FK to ON DELETE
|
||||
-- NO ACTION so the database fails closed: an application with connections can never be deleted,
|
||||
-- and a concurrently-inserted connection (which holds a FOR KEY SHARE lock on the parent row)
|
||||
-- forces the delete to raise a foreign_key_violation rather than cascade.
|
||||
--
|
||||
-- NO ACTION (end-of-statement check) rather than RESTRICT (immediate check) is deliberate so a
|
||||
-- company delete still cascades cleanly: companies -> tool_applications and companies ->
|
||||
-- tool_connections both fire within the one DELETE statement, and the connections are already
|
||||
-- gone by the time this constraint is verified.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_connections_application_id_tool_applications_id_fk') THEN
|
||||
ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_application_id_tool_applications_id_fk";
|
||||
END IF;
|
||||
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE no action ON UPDATE no action;
|
||||
END $$;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_stdio_command_templates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"template_key" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"command" text NOT NULL,
|
||||
"args" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"env_keys" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"tools" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"disabled_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_stdio_command_templates_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_stdio_command_templates" ADD CONSTRAINT "tool_stdio_command_templates_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_stdio_command_templates_created_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_stdio_command_templates" ADD CONSTRAINT "tool_stdio_command_templates_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_stdio_command_templates_company_idx" ON "tool_stdio_command_templates" USING btree ("company_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_stdio_command_templates_company_status_idx" ON "tool_stdio_command_templates" USING btree ("company_id", "status");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_stdio_command_templates_company_key_uq" ON "tool_stdio_command_templates" USING btree ("company_id", "template_key");
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_oauth_states" (
|
||||
"state" text PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"connection_id" uuid NOT NULL,
|
||||
"code_verifier" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_oauth_states_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tool_oauth_states"
|
||||
ADD CONSTRAINT "tool_oauth_states_company_id_companies_id_fk"
|
||||
FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_oauth_states_connection_id_tool_connections_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tool_oauth_states"
|
||||
ADD CONSTRAINT "tool_oauth_states_connection_id_tool_connections_id_fk"
|
||||
FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "tool_oauth_states_company_idx" ON "tool_oauth_states" USING btree ("company_id");
|
||||
CREATE INDEX IF NOT EXISTS "tool_oauth_states_connection_idx" ON "tool_oauth_states" USING btree ("connection_id");
|
||||
CREATE INDEX IF NOT EXISTS "tool_oauth_states_expires_at_idx" ON "tool_oauth_states" USING btree ("expires_at");
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_actor_type" text;
|
||||
ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_actor_id" text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "tool_oauth_states_actor_idx"
|
||||
ON "tool_oauth_states" USING btree ("created_by_actor_type", "created_by_actor_id");
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "tool_oauth_states" ADD COLUMN IF NOT EXISTS "created_by_session_id" text;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "tool_profiles" ADD COLUMN IF NOT EXISTS "new_tools_reviewed_at" timestamp with time zone;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "provider_type" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "application_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "upstream_tool_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "risk_level" text;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_mcp_gateways" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"description" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"agent_id" uuid,
|
||||
"project_id" uuid,
|
||||
"issue_id" uuid,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "tool_mcp_gateway_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"gateway_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_profile_id_tool_profiles_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_profile_id_tool_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."tool_profiles"("id") ON DELETE restrict ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_project_id_projects_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateway_tokens_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD CONSTRAINT "tool_mcp_gateway_tokens_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD CONSTRAINT "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_mcp_gateways_company_idx" ON "tool_mcp_gateways" USING btree ("company_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_mcp_gateways_company_status_idx" ON "tool_mcp_gateways" USING btree ("company_id", "status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_mcp_gateways_profile_idx" ON "tool_mcp_gateways" USING btree ("company_id", "profile_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateways_company_slug_uq" ON "tool_mcp_gateways" USING btree ("company_id", "slug");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateways_company_name_uq" ON "tool_mcp_gateways" USING btree ("company_id", "name");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_token_hash_uq" ON "tool_mcp_gateway_tokens" USING btree ("token_hash");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_gateway_idx" ON "tool_mcp_gateway_tokens" USING btree ("company_id", "gateway_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_company_expires_idx" ON "tool_mcp_gateway_tokens" USING btree ("company_id", "expires_at");
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "gateway_public_id" text;
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_mcp_gateways"
|
||||
SET "gateway_public_id" = 'gw_' || replace("id"::text, '-', '')
|
||||
WHERE "gateway_public_id" IS NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "gateway_public_id" SET DEFAULT ('gw_' || replace(gen_random_uuid()::text, '-', ''));
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "gateway_public_id" SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "display_slug" text;
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_mcp_gateways"
|
||||
SET "display_slug" = "slug"
|
||||
WHERE "display_slug" IS NULL OR "display_slug" = '';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "display_slug" SET DEFAULT '';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ALTER COLUMN "display_slug" SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "default_profile_mode" text DEFAULT 'gateway_only' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "context_scope_type" text DEFAULT 'none' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "context_scope_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "approval_issue_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "auth_config" jsonb DEFAULT '{"version":1,"bearer":{"enabled":true,"tokenPrefix":"pcgw","defaultTtlSeconds":7776000,"requireFiniteExpiry":true,"longLivedTokenRequiresOverride":true},"oauth":{"enabled":false,"reservedFor":"v1_5","dynamicClientRegistration":false,"authorizationCodePkce":false}}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "header_policy" jsonb DEFAULT '{"version":1,"callerPassthrough":{"enabled":false,"allowedHeaders":[]},"staticHeaders":[],"generatedMetadata":{"enabled":false,"allowedHeaders":[]},"responseHeaders":{"forwardMcpRequiredHeaders":true,"forwardSafeCacheHeaders":true}}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "metadata_policy" jsonb DEFAULT '{"version":1,"forwardCompanyId":false,"forwardGatewayId":false,"forwardProjectId":false,"forwardIssueId":false,"forwardAgentId":false,"forwardRunId":false,"forwardCorrelationId":true}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "on_demand_tools_config" jsonb DEFAULT '{"enabled":false,"searchToolName":"search_tools","runToolName":"run_tool"}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateways" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "token_prefix" text DEFAULT '' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "subject_type" text DEFAULT 'gateway_client' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "subject_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "client_label" text DEFAULT '' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE "tool_mcp_gateway_tokens"
|
||||
SET "client_label" = "name"
|
||||
WHERE "client_label" = '';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "owner_note" text DEFAULT '' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "allowed_actions" jsonb DEFAULT '["tools/list","tools/call"]'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_reason" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_by_user_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_by_agent_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "expiry_override_at" timestamp with time zone;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "gateway_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "gateway_public_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "client_subject_type" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "client_subject_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "client_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "mcp_session_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_gateway_sessions" ADD COLUMN IF NOT EXISTS "correlation_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "gateway_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "gateway_public_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "client_subject_type" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "client_subject_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "client_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "mcp_session_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "correlation_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "policy_explanation" jsonb;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "credential_scope_summary" jsonb;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_invocations" ADD COLUMN IF NOT EXISTS "header_policy_summary" jsonb;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "gateway_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "gateway_public_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "client_subject_type" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "client_subject_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "client_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "mcp_session_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "correlation_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "policy_explanation" jsonb;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "credential_scope_summary" jsonb;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_call_events" ADD COLUMN IF NOT EXISTS "header_policy_summary" jsonb;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "gateway_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "gateway_token_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "gateway_public_id" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "client_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "tool_access_audit_events" ADD COLUMN IF NOT EXISTS "correlation_id" text;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateways_approval_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateways" ADD CONSTRAINT "tool_mcp_gateways_approval_issue_id_issues_id_fk" FOREIGN KEY ("approval_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD CONSTRAINT "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk" FOREIGN KEY ("expiry_override_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk') THEN
|
||||
ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN
|
||||
ALTER TABLE "tool_gateway_sessions" ADD CONSTRAINT "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_gateway_id_tool_mcp_gateways_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN
|
||||
ALTER TABLE "tool_invocations" ADD CONSTRAINT "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_gateway_id_tool_mcp_gateways_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN
|
||||
ALTER TABLE "tool_call_events" ADD CONSTRAINT "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk') THEN
|
||||
ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk" FOREIGN KEY ("gateway_id") REFERENCES "public"."tool_mcp_gateways"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk') THEN
|
||||
ALTER TABLE "tool_access_audit_events" ADD CONSTRAINT "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk" FOREIGN KEY ("gateway_token_id") REFERENCES "public"."tool_mcp_gateway_tokens"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_mcp_gateways_public_id_uq" ON "tool_mcp_gateways" USING btree ("gateway_public_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_mcp_gateway_tokens_subject_idx" ON "tool_mcp_gateway_tokens" USING btree ("company_id", "subject_type", "subject_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_gateway_sessions_gateway_idx" ON "tool_gateway_sessions" USING btree ("company_id", "gateway_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_invocations_gateway_idx" ON "tool_invocations" USING btree ("company_id", "gateway_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_call_events_gateway_idx" ON "tool_call_events" USING btree ("company_id", "gateway_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_access_audit_gateway_idx" ON "tool_access_audit_events" USING btree ("company_id", "gateway_id");
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- No-op retained for migration ordering compatibility.
|
||||
--
|
||||
-- This branch previously carried a company-scope repair for environment custom
|
||||
-- images, but origin/master intentionally restored custom images to the
|
||||
-- instance-scoped environment model in 0127. Reapplying company scope here
|
||||
-- contradicts the checked-in Drizzle schema and can fail against cloned
|
||||
-- multi-company dev databases.
|
||||
SELECT 1;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_runtime_metric_counters" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"metric" text NOT NULL,
|
||||
"bucket_start_at" timestamp with time zone NOT NULL,
|
||||
"count" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tool_runtime_metric_counters_count_nonnegative" CHECK ("count" >= 0)
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_runtime_metric_counters_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tool_runtime_metric_counters"
|
||||
ADD CONSTRAINT "tool_runtime_metric_counters_company_id_companies_id_fk"
|
||||
FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "tool_runtime_metric_counters_company_metric_idx"
|
||||
ON "tool_runtime_metric_counters" USING btree ("company_id", "metric", "bucket_start_at");
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_runtime_metric_counters_bucket_uq"
|
||||
ON "tool_runtime_metric_counters" USING btree ("company_id", "metric", "bucket_start_at");
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE "company_secret_bindings" ADD COLUMN IF NOT EXISTS "projection_class" text DEFAULT 'unclassified' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "company_secret_bindings" ADD COLUMN IF NOT EXISTS "projection_allowlist_key" text;
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
-- Scope plugin configuration rows by company before re-enabling plugin secret
|
||||
-- refs. Legacy rows were instance-global; preserve only rows with an
|
||||
-- unambiguous company owner and fail closed when a row is ambiguous.
|
||||
|
||||
ALTER TABLE "plugin_config"
|
||||
ADD COLUMN IF NOT EXISTS "company_id" uuid;--> statement-breakpoint
|
||||
|
||||
WITH binding_owner AS (
|
||||
SELECT
|
||||
pc."id" AS config_id,
|
||||
min(csb."company_id"::text)::uuid AS company_id,
|
||||
count(DISTINCT csb."company_id") AS company_count
|
||||
FROM "plugin_config" pc
|
||||
JOIN "company_secret_bindings" csb
|
||||
ON csb."target_type" = 'plugin'
|
||||
AND csb."target_id" = pc."plugin_id"::text
|
||||
GROUP BY pc."id"
|
||||
)
|
||||
UPDATE "plugin_config" pc
|
||||
SET "company_id" = bo."company_id"
|
||||
FROM binding_owner bo
|
||||
WHERE pc."company_id" IS NULL
|
||||
AND bo."config_id" = pc."id"
|
||||
AND bo."company_count" = 1;--> statement-breakpoint
|
||||
|
||||
WITH single_company AS (
|
||||
SELECT min("id"::text)::uuid AS company_id, count(*) AS company_count
|
||||
FROM "companies"
|
||||
)
|
||||
UPDATE "plugin_config" pc
|
||||
SET "company_id" = sc."company_id"
|
||||
FROM single_company sc
|
||||
WHERE pc."company_id" IS NULL
|
||||
AND sc."company_count" = 1;--> statement-breakpoint
|
||||
|
||||
DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";--> statement-breakpoint
|
||||
|
||||
WITH unbound_config AS (
|
||||
SELECT pc.*
|
||||
FROM "plugin_config" pc
|
||||
WHERE pc."company_id" IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "company_secret_bindings" csb
|
||||
WHERE csb."target_type" = 'plugin'
|
||||
AND csb."target_id" = pc."plugin_id"::text
|
||||
)
|
||||
), primary_company AS (
|
||||
SELECT min("id"::text)::uuid AS company_id
|
||||
FROM "companies"
|
||||
)
|
||||
INSERT INTO "plugin_config" (
|
||||
"plugin_id",
|
||||
"company_id",
|
||||
"config_json",
|
||||
"last_error",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
uc."plugin_id",
|
||||
c."id",
|
||||
uc."config_json",
|
||||
uc."last_error",
|
||||
uc."created_at",
|
||||
uc."updated_at"
|
||||
FROM unbound_config uc
|
||||
CROSS JOIN "companies" c
|
||||
CROSS JOIN primary_company pc
|
||||
WHERE c."id" <> pc."company_id";--> statement-breakpoint
|
||||
|
||||
WITH primary_company AS (
|
||||
SELECT min("id"::text)::uuid AS company_id
|
||||
FROM "companies"
|
||||
)
|
||||
UPDATE "plugin_config" pc
|
||||
SET "company_id" = primary_company."company_id"
|
||||
FROM primary_company
|
||||
WHERE pc."company_id" IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "company_secret_bindings" csb
|
||||
WHERE csb."target_type" = 'plugin'
|
||||
AND csb."target_id" = pc."plugin_id"::text
|
||||
);--> statement-breakpoint
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
unresolved_count integer;
|
||||
BEGIN
|
||||
SELECT count(*) INTO unresolved_count
|
||||
FROM "plugin_config"
|
||||
WHERE "company_id" IS NULL;
|
||||
|
||||
IF unresolved_count > 0 THEN
|
||||
RAISE EXCEPTION 'Cannot assign company_id for % plugin_config row(s); resolve ambiguous plugin secret bindings before applying migration 0164', unresolved_count;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
|
||||
ALTER TABLE "plugin_config"
|
||||
ALTER COLUMN "company_id" SET NOT NULL;--> statement-breakpoint
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'plugin_config_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "plugin_config"
|
||||
ADD CONSTRAINT "plugin_config_company_id_companies_id_fk"
|
||||
FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_config_plugin_company_idx"
|
||||
ON "plugin_config" USING btree ("plugin_id", "company_id");
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
CREATE TABLE IF NOT EXISTS "connection_token_issuances" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"application_id" uuid,
|
||||
"connection_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"run_id" uuid,
|
||||
"issue_id" uuid,
|
||||
"project_id" uuid,
|
||||
"responsible_user_id" text,
|
||||
"path" text NOT NULL,
|
||||
"requested_scope" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"issued_scope" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"ttl_seconds" integer,
|
||||
"expires_at" timestamp with time zone,
|
||||
"token_hash" text,
|
||||
"outcome" text NOT NULL,
|
||||
"error_code" text,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "connection_token_issuances_path_check" CHECK ("connection_token_issuances"."path" IN ('exchange', 'oauth_access', 'static')),
|
||||
CONSTRAINT "connection_token_issuances_outcome_check" CHECK ("connection_token_issuances"."outcome" IN ('success', 'denied', 'rate_limited', 'use_env_lease', 'upstream_error', 'failure')),
|
||||
CONSTRAINT "connection_token_issuances_ttl_bounds" CHECK ("connection_token_issuances"."ttl_seconds" IS NULL OR ("connection_token_issuances"."ttl_seconds" >= 1 AND "connection_token_issuances"."ttl_seconds" <= 900)),
|
||||
CONSTRAINT "connection_token_issuances_token_hash_format" CHECK ("connection_token_issuances"."token_hash" IS NULL OR "connection_token_issuances"."token_hash" ~ '^[a-f0-9]{64}$')
|
||||
);--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_application_id_tool_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."tool_applications"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_connection_id_tool_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "connection_token_issuances" ADD CONSTRAINT "connection_token_issuances_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "connection_token_issuances_company_created_idx" ON "connection_token_issuances" USING btree ("company_id", "created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "connection_token_issuances_connection_created_idx" ON "connection_token_issuances" USING btree ("company_id", "connection_id", "created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "connection_token_issuances_agent_connection_idx" ON "connection_token_issuances" USING btree ("company_id", "agent_id", "connection_id", "created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "connection_token_issuances_run_idx" ON "connection_token_issuances" USING btree ("company_id", "run_id");
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
CREATE TABLE IF NOT EXISTS "smoke_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"trigger" text NOT NULL,
|
||||
"status" text DEFAULT 'running' NOT NULL,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"finished_at" timestamp with time zone,
|
||||
"summary" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "smoke_run_steps" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"path" text NOT NULL,
|
||||
"scenario_step" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"detail" text,
|
||||
"screenshot_artifact_ref" jsonb,
|
||||
"duration_ms" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "smoke_runs" ADD CONSTRAINT "smoke_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "smoke_run_steps" ADD CONSTRAINT "smoke_run_steps_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "smoke_run_steps" ADD CONSTRAINT "smoke_run_steps_run_id_smoke_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."smoke_runs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "smoke_runs_company_started_idx" ON "smoke_runs" USING btree ("company_id", "started_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "smoke_runs_company_status_idx" ON "smoke_runs" USING btree ("company_id", "status");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "smoke_run_steps_company_run_idx" ON "smoke_run_steps" USING btree ("company_id", "run_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "smoke_run_steps_company_path_idx" ON "smoke_run_steps" USING btree ("company_id", "path");
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
-- Keep environment custom image tables aligned with the instance-scoped schema.
|
||||
-- This is safe for databases that never ran the stale 0158 company-scope repair
|
||||
-- and repairs databases that did run it before 0158 was neutralized.
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_status_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_provider_status_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_active_uq";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_templates_company_last_used_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_status_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_active_uq";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_template_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_promoted_template_idx";
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_expires_idx";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_templates"
|
||||
DROP CONSTRAINT IF EXISTS "environment_custom_image_templates_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_setup_sessions"
|
||||
DROP CONSTRAINT IF EXISTS "environment_custom_image_setup_sessions_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_templates"
|
||||
DROP COLUMN IF EXISTS "company_id";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_custom_image_setup_sessions"
|
||||
DROP COLUMN IF EXISTS "company_id";
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_connection_installs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"connection_id" uuid NOT NULL,
|
||||
"target_type" text NOT NULL,
|
||||
"target_id" text NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tool_connection_installs_target_type_check" CHECK ("target_type" in ('company', 'agent'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_connection_installs_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tool_connection_installs"
|
||||
ADD CONSTRAINT "tool_connection_installs_company_id_companies_id_fk"
|
||||
FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_connection_installs_connection_id_tool_connections_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tool_connection_installs"
|
||||
ADD CONSTRAINT "tool_connection_installs_connection_id_tool_connections_id_fk"
|
||||
FOREIGN KEY ("connection_id") REFERENCES "public"."tool_connections"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_connection_installs_created_by_agent_id_agents_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tool_connection_installs"
|
||||
ADD CONSTRAINT "tool_connection_installs_created_by_agent_id_agents_id_fk"
|
||||
FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id")
|
||||
ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_connection_installs_company_target_idx" ON "tool_connection_installs" USING btree ("company_id","target_type","target_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_connection_installs_connection_idx" ON "tool_connection_installs" USING btree ("company_id","connection_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_connection_installs_target_uq" ON "tool_connection_installs" USING btree ("company_id","connection_id","target_type","target_id");
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
CREATE TABLE IF NOT EXISTS "tool_gateway_rate_limit_counters" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"counter_key" text NOT NULL,
|
||||
"window_start_at" timestamp with time zone NOT NULL,
|
||||
"window_ms" integer NOT NULL,
|
||||
"limit" integer NOT NULL,
|
||||
"count" integer DEFAULT 0 NOT NULL,
|
||||
"reset_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_gateway_rate_limit_counters_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "tool_gateway_rate_limit_counters"
|
||||
ADD CONSTRAINT "tool_gateway_rate_limit_counters_company_id_companies_id_fk"
|
||||
FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id")
|
||||
ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tool_gateway_rate_limit_counters_window_bounds'
|
||||
) THEN
|
||||
ALTER TABLE "tool_gateway_rate_limit_counters"
|
||||
ADD CONSTRAINT "tool_gateway_rate_limit_counters_window_bounds"
|
||||
CHECK ("window_ms" > 0 AND "limit" > 0 AND "count" >= 0);
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_gateway_rate_limit_counters_company_idx"
|
||||
ON "tool_gateway_rate_limit_counters" USING btree ("company_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tool_gateway_rate_limit_counters_window_uq"
|
||||
ON "tool_gateway_rate_limit_counters" USING btree ("company_id","counter_key","window_start_at");
|
||||
|
|
@ -1023,6 +1023,160 @@
|
|||
"when": 1783953514660,
|
||||
"tag": "0147_cost_event_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 148,
|
||||
"version": "7",
|
||||
"when": 1783953515660,
|
||||
"tag": "0148_tool_access_mcp_connections",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 149,
|
||||
"version": "7",
|
||||
"when": 1783953516660,
|
||||
"tag": "0149_agent_access_phase2_contracts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 150,
|
||||
"version": "7",
|
||||
"when": 1783953517660,
|
||||
"tag": "0150_tool_invocation_catalog_snapshots",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 151,
|
||||
"version": "7",
|
||||
"when": 1783953518660,
|
||||
"tag": "0151_tool_gateway_sessions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 152,
|
||||
"version": "7",
|
||||
"when": 1783953519660,
|
||||
"tag": "0152_tool_connection_application_no_cascade",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 153,
|
||||
"version": "7",
|
||||
"when": 1783953520660,
|
||||
"tag": "0153_tool_stdio_command_templates",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 154,
|
||||
"version": "7",
|
||||
"when": 1783953521660,
|
||||
"tag": "0154_tool_oauth_states",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 155,
|
||||
"version": "7",
|
||||
"when": 1783953522660,
|
||||
"tag": "0155_tool_oauth_state_actor_binding",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 156,
|
||||
"version": "7",
|
||||
"when": 1783953523660,
|
||||
"tag": "0156_tool_oauth_state_session_binding",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 157,
|
||||
"version": "7",
|
||||
"when": 1783953524660,
|
||||
"tag": "0157_tool_profile_new_tools_review",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 158,
|
||||
"version": "7",
|
||||
"when": 1783953525660,
|
||||
"tag": "0158_tool_invocation_connected_mcp_metadata",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 159,
|
||||
"version": "7",
|
||||
"when": 1783953526660,
|
||||
"tag": "0159_named_mcp_gateways",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 160,
|
||||
"version": "7",
|
||||
"when": 1783953527660,
|
||||
"tag": "0160_mcp_gateway_contract_expansion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 161,
|
||||
"version": "7",
|
||||
"when": 1783953528660,
|
||||
"tag": "0161_environment_custom_image_company_scope_repair",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 162,
|
||||
"version": "7",
|
||||
"when": 1783953529660,
|
||||
"tag": "0162_tool_runtime_metric_counters",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 163,
|
||||
"version": "7",
|
||||
"when": 1783953530660,
|
||||
"tag": "0163_secret_binding_projection_class",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 164,
|
||||
"version": "7",
|
||||
"when": 1783953531660,
|
||||
"tag": "0164_plugin_config_company_scope",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 165,
|
||||
"version": "7",
|
||||
"when": 1783953532660,
|
||||
"tag": "0165_connection_token_issuances",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 166,
|
||||
"version": "7",
|
||||
"when": 1783953533660,
|
||||
"tag": "0166_smoke_lab_results",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 167,
|
||||
"version": "7",
|
||||
"when": 1783953534660,
|
||||
"tag": "0167_environment_custom_image_instance_scope_cleanup",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 168,
|
||||
"version": "7",
|
||||
"when": 1783953535660,
|
||||
"tag": "0168_tool_connection_installs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 169,
|
||||
"version": "7",
|
||||
"when": 1783953536660,
|
||||
"tag": "0169_tool_gateway_protocol_rate_limit_counters",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export const companySecretBindings = pgTable(
|
|||
versionSelector: text("version_selector").notNull().default("latest"),
|
||||
required: boolean("required").notNull().default(true),
|
||||
label: text("label"),
|
||||
projectionClass: text("projection_class").notNull().default("unclassified"),
|
||||
projectionAllowlistKey: text("projection_allowlist_key"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_
|
|||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js";
|
||||
export { smokeRuns, smokeRunSteps } from "./smoke_lab.js";
|
||||
export { costEvents } from "./cost_events.js";
|
||||
export { financeEvents } from "./finance_events.js";
|
||||
export { approvals } from "./approvals.js";
|
||||
|
|
@ -99,6 +100,30 @@ export { companySecretVersions } from "./company_secret_versions.js";
|
|||
export { companySecretBindings } from "./company_secret_bindings.js";
|
||||
export { userSecretDeclarations } from "./user_secret_declarations.js";
|
||||
export { secretAccessEvents } from "./secret_access_events.js";
|
||||
export {
|
||||
toolApplications,
|
||||
toolConnections,
|
||||
toolConnectionInstalls,
|
||||
toolOauthStates,
|
||||
toolCatalogEntries,
|
||||
toolProfiles,
|
||||
toolProfileEntries,
|
||||
toolProfileBindings,
|
||||
toolMcpGateways,
|
||||
toolMcpGatewayTokens,
|
||||
toolPolicies,
|
||||
toolRuntimeSlots,
|
||||
toolRuntimeMetricCounters,
|
||||
toolStdioCommandTemplates,
|
||||
toolGatewaySessions,
|
||||
connectionTokenIssuances,
|
||||
toolInvocations,
|
||||
toolActionRequests,
|
||||
toolCallEvents,
|
||||
toolRateLimitCounters,
|
||||
toolGatewayRateLimitCounters,
|
||||
toolAccessAuditEvents,
|
||||
} from "./tool_access.js";
|
||||
export {
|
||||
companySkills,
|
||||
companySkillVersions,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { plugins } from "./plugins.js";
|
|||
* `plugin_company_settings` table — stores operator-managed plugin settings
|
||||
* scoped to a specific company.
|
||||
*
|
||||
* This is distinct from `plugin_config`, which stores instance-wide plugin
|
||||
* configuration. Each company can have at most one settings row per plugin.
|
||||
* This is distinct from `plugin_config`, which stores the plugin's declared
|
||||
* operator configuration. Each company can have at most one settings row per
|
||||
* plugin.
|
||||
*
|
||||
* Rows represent explicit overrides from the default company behavior:
|
||||
* - no row => plugin is enabled for the company by default
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_config` table — stores operator-provided instance configuration
|
||||
* for each plugin (one row per plugin, enforced by a unique index on
|
||||
* `plugin_id`).
|
||||
* `plugin_config` table — stores operator-provided configuration for each
|
||||
* plugin within a company (one row per plugin/company pair).
|
||||
*
|
||||
* The `config_json` column holds the values that the operator enters in the
|
||||
* plugin settings UI. These values are validated at runtime against the
|
||||
|
|
@ -19,12 +19,18 @@ export const pluginConfig = pgTable(
|
|||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
|
||||
pluginCompanyIdx: uniqueIndex("plugin_config_plugin_company_idx").on(
|
||||
table.pluginId,
|
||||
table.companyId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import type {
|
||||
SmokeRunStatus,
|
||||
SmokeRunStepPath,
|
||||
SmokeRunStepStatus,
|
||||
SmokeRunTrigger,
|
||||
} from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
|
||||
export const smokeRuns = pgTable(
|
||||
"smoke_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
trigger: text("trigger").$type<SmokeRunTrigger>().notNull(),
|
||||
status: text("status").$type<SmokeRunStatus>().notNull().default("running"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
summary: jsonb("summary").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("smoke_runs_company_started_idx").on(table.companyId, table.startedAt),
|
||||
index("smoke_runs_company_status_idx").on(table.companyId, table.status),
|
||||
],
|
||||
);
|
||||
|
||||
export const smokeRunSteps = pgTable(
|
||||
"smoke_run_steps",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
runId: uuid("run_id").notNull().references(() => smokeRuns.id, { onDelete: "cascade" }),
|
||||
path: text("path").$type<SmokeRunStepPath>().notNull(),
|
||||
scenarioStep: text("scenario_step").notNull(),
|
||||
status: text("status").$type<SmokeRunStepStatus>().notNull(),
|
||||
detail: text("detail"),
|
||||
screenshotArtifactRef: jsonb("screenshot_artifact_ref").$type<Record<string, unknown>>(),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("smoke_run_steps_company_run_idx").on(table.companyId, table.runId),
|
||||
index("smoke_run_steps_company_path_idx").on(table.companyId, table.path),
|
||||
],
|
||||
);
|
||||
|
|
@ -0,0 +1,813 @@
|
|||
import {
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type {
|
||||
ConnectionTokenIssuanceOutcome,
|
||||
ConnectionTokenIssuancePath,
|
||||
McpConnectionCredentialRef,
|
||||
ToolActionRequestStatus,
|
||||
ToolApplicationStatus,
|
||||
ToolApplicationType,
|
||||
ToolAuditEventType,
|
||||
ToolAuditOutcome,
|
||||
ToolCatalogEntryKind,
|
||||
ToolCatalogEntryStatus,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolConnectionKind,
|
||||
ToolConnectionInstallTargetType,
|
||||
ToolConnectionStatus,
|
||||
ToolConnectionTransport,
|
||||
ToolCredentialSecretRef,
|
||||
ToolInvocationApprovalState,
|
||||
ToolInvocationStatus,
|
||||
ToolMcpGatewayAuthConfig,
|
||||
ToolMcpGatewayContextScopeType,
|
||||
ToolMcpGatewayDefaultProfileMode,
|
||||
ToolMcpGatewayHeaderPolicy,
|
||||
ToolMcpGatewayMetadataPolicy,
|
||||
ToolMcpGatewayOnDemandToolsConfig,
|
||||
ToolMcpGatewayStatus,
|
||||
ToolMcpGatewayTokenAction,
|
||||
ToolMcpGatewayTokenSubjectType,
|
||||
ToolPolicyDecision,
|
||||
ToolPolicyType,
|
||||
ToolProfileBindingTargetType,
|
||||
ToolProfileDefaultAction,
|
||||
ToolProfileEntryEffect,
|
||||
ToolProfileEntrySelectorType,
|
||||
ToolProfileStatus,
|
||||
ToolRateLimitWindowKind,
|
||||
ToolRedactedValueSummary,
|
||||
ToolRiskLevel,
|
||||
ToolRuntimeKind,
|
||||
ToolRuntimeSlotStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { agents } from "./agents.js";
|
||||
import { approvals } from "./approvals.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { executionWorkspaces } from "./execution_workspaces.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { issueThreadInteractions } from "./issue_thread_interactions.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { projectWorkspaces } from "./project_workspaces.js";
|
||||
|
||||
export const toolApplications = pgTable(
|
||||
"tool_applications",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
applicationKey: text("application_key"),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
type: text("type").$type<ToolApplicationType>().notNull(),
|
||||
status: text("status").$type<ToolApplicationStatus>().notNull().default("active"),
|
||||
pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }),
|
||||
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
ownerUserId: text("owner_user_id"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_applications_company_idx").on(table.companyId),
|
||||
index("tool_applications_company_status_idx").on(table.companyId, table.status),
|
||||
uniqueIndex("tool_applications_company_name_uq").on(table.companyId, table.name),
|
||||
uniqueIndex("tool_applications_company_key_uq").on(table.companyId, table.applicationKey),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolConnections = pgTable(
|
||||
"tool_connections",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
// NO ACTION (not CASCADE) so the database itself refuses to delete an application that still
|
||||
// has connections. This closes the delete-vs-create race in DELETE
|
||||
// /tool-applications/:applicationId: a connection inserted concurrently takes a FOR KEY SHARE
|
||||
// lock on the parent row, so the delete fails closed with a foreign-key violation instead of
|
||||
// silently cascading the new connection away. NO ACTION (checked at end-of-statement) rather
|
||||
// than RESTRICT (checked immediately) is deliberate: it lets a company delete still cascade —
|
||||
// companies → tool_applications and companies → tool_connections both fire in one statement,
|
||||
// and the connections are gone by the time this constraint is checked.
|
||||
applicationId: uuid("application_id").notNull().references(() => toolApplications.id, { onDelete: "no action" }),
|
||||
name: text("name").notNull(),
|
||||
connectionKind: text("connection_kind").$type<ToolConnectionKind>().notNull().default("managed"),
|
||||
transport: text("transport").$type<ToolConnectionTransport>().notNull(),
|
||||
status: text("status").$type<ToolConnectionStatus>().notNull().default("draft"),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
transportConfig: jsonb("transport_config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
credentialRefs: jsonb("credential_refs").$type<McpConnectionCredentialRef[]>().notNull().default([]),
|
||||
credentialSecretRefs: jsonb("credential_secret_refs").$type<ToolCredentialSecretRef[]>().notNull().default([]),
|
||||
healthStatus: text("health_status").$type<ToolConnectionHealthStatus>().notNull().default("unchecked"),
|
||||
healthMessage: text("health_message"),
|
||||
healthCheckedAt: timestamp("health_checked_at", { withTimezone: true }),
|
||||
lastHealthAt: timestamp("last_health_at", { withTimezone: true }),
|
||||
lastCatalogRefreshAt: timestamp("last_catalog_refresh_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_connections_company_idx").on(table.companyId),
|
||||
index("tool_connections_application_idx").on(table.applicationId),
|
||||
index("tool_connections_company_enabled_idx").on(table.companyId, table.enabled),
|
||||
uniqueIndex("tool_connections_company_name_uq").on(table.companyId, table.name),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolConnectionInstalls = pgTable(
|
||||
"tool_connection_installs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }),
|
||||
targetType: text("target_type").$type<ToolConnectionInstallTargetType>().notNull(),
|
||||
targetId: text("target_id").notNull(),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
check("tool_connection_installs_target_type_check", sql`${table.targetType} in ('company', 'agent')`),
|
||||
index("tool_connection_installs_company_target_idx").on(table.companyId, table.targetType, table.targetId),
|
||||
index("tool_connection_installs_connection_idx").on(table.companyId, table.connectionId),
|
||||
uniqueIndex("tool_connection_installs_target_uq").on(
|
||||
table.companyId,
|
||||
table.connectionId,
|
||||
table.targetType,
|
||||
table.targetId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolOauthStates = pgTable(
|
||||
"tool_oauth_states",
|
||||
{
|
||||
state: text("state").primaryKey(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }),
|
||||
codeVerifier: text("code_verifier").notNull(),
|
||||
createdByActorType: text("created_by_actor_type"),
|
||||
createdByActorId: text("created_by_actor_id"),
|
||||
createdBySessionId: text("created_by_session_id"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_oauth_states_company_idx").on(table.companyId),
|
||||
index("tool_oauth_states_connection_idx").on(table.connectionId),
|
||||
index("tool_oauth_states_actor_idx").on(table.createdByActorType, table.createdByActorId),
|
||||
index("tool_oauth_states_expires_at_idx").on(table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolCatalogEntries = pgTable(
|
||||
"tool_catalog_entries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "cascade" }),
|
||||
connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }),
|
||||
entryKind: text("entry_kind").$type<ToolCatalogEntryKind>().notNull().default("tool"),
|
||||
name: text("name").notNull(),
|
||||
toolName: text("tool_name").notNull(),
|
||||
title: text("title"),
|
||||
description: text("description"),
|
||||
inputSchema: jsonb("input_schema").$type<Record<string, unknown>>().notNull().default({}),
|
||||
outputSchema: jsonb("output_schema").$type<Record<string, unknown>>(),
|
||||
annotations: jsonb("annotations").$type<Record<string, unknown>>().notNull().default({}),
|
||||
riskLevel: text("risk_level").$type<ToolRiskLevel>().notNull().default("read"),
|
||||
isReadOnly: boolean("is_read_only").notNull().default(true),
|
||||
isWrite: boolean("is_write").notNull().default(false),
|
||||
isDestructive: boolean("is_destructive").notNull().default(false),
|
||||
status: text("status").$type<ToolCatalogEntryStatus>().notNull().default("active"),
|
||||
version: text("version"),
|
||||
versionHash: text("version_hash").notNull(),
|
||||
schemaHash: text("schema_hash"),
|
||||
firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
|
||||
reviewedByAgentId: uuid("reviewed_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
reviewedByUserId: text("reviewed_by_user_id"),
|
||||
quarantinedAt: timestamp("quarantined_at", { withTimezone: true }),
|
||||
quarantineReason: text("quarantine_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_catalog_entries_company_idx").on(table.companyId),
|
||||
index("tool_catalog_entries_application_idx").on(table.applicationId),
|
||||
index("tool_catalog_entries_connection_idx").on(table.connectionId),
|
||||
index("tool_catalog_entries_company_status_idx").on(table.companyId, table.status),
|
||||
uniqueIndex("tool_catalog_entries_connection_name_uq").on(table.connectionId, table.name),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolProfiles = pgTable(
|
||||
"tool_profiles",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
profileKey: text("profile_key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").$type<ToolProfileStatus>().notNull().default("active"),
|
||||
defaultAction: text("default_action").$type<ToolProfileDefaultAction>().notNull().default("deny"),
|
||||
newToolsReviewedAt: timestamp("new_tools_reviewed_at", { withTimezone: true }),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_profiles_company_status_idx").on(table.companyId, table.status),
|
||||
uniqueIndex("tool_profiles_company_key_uq").on(table.companyId, table.profileKey),
|
||||
uniqueIndex("tool_profiles_company_name_uq").on(table.companyId, table.name),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolProfileEntries = pgTable(
|
||||
"tool_profile_entries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
profileId: uuid("profile_id").notNull().references(() => toolProfiles.id, { onDelete: "cascade" }),
|
||||
selectorType: text("selector_type").$type<ToolProfileEntrySelectorType>().notNull(),
|
||||
effect: text("effect").$type<ToolProfileEntryEffect>().notNull().default("include"),
|
||||
applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "cascade" }),
|
||||
connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "cascade" }),
|
||||
catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "cascade" }),
|
||||
toolName: text("tool_name"),
|
||||
riskLevel: text("risk_level").$type<ToolRiskLevel>(),
|
||||
conditions: jsonb("conditions").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_profile_entries_company_profile_idx").on(table.companyId, table.profileId),
|
||||
index("tool_profile_entries_application_idx").on(table.companyId, table.applicationId),
|
||||
index("tool_profile_entries_connection_idx").on(table.companyId, table.connectionId),
|
||||
index("tool_profile_entries_catalog_entry_idx").on(table.companyId, table.catalogEntryId),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolProfileBindings = pgTable(
|
||||
"tool_profile_bindings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
profileId: uuid("profile_id").notNull().references(() => toolProfiles.id, { onDelete: "cascade" }),
|
||||
targetType: text("target_type").$type<ToolProfileBindingTargetType>().notNull(),
|
||||
targetId: text("target_id").notNull(),
|
||||
priority: integer("priority").notNull().default(100),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_profile_bindings_company_target_idx").on(table.companyId, table.targetType, table.targetId),
|
||||
uniqueIndex("tool_profile_bindings_target_profile_uq").on(
|
||||
table.companyId,
|
||||
table.targetType,
|
||||
table.targetId,
|
||||
table.profileId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolMcpGateways = pgTable(
|
||||
"tool_mcp_gateways",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
gatewayPublicId: text("gateway_public_id").notNull().default(sql`'gw_' || replace(gen_random_uuid()::text, '-', '')`),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
displaySlug: text("display_slug").notNull().default(""),
|
||||
description: text("description"),
|
||||
status: text("status").$type<ToolMcpGatewayStatus>().notNull().default("active"),
|
||||
profileId: uuid("profile_id").notNull().references(() => toolProfiles.id, { onDelete: "restrict" }),
|
||||
defaultProfileMode: text("default_profile_mode").$type<ToolMcpGatewayDefaultProfileMode>().notNull().default("gateway_only"),
|
||||
contextScopeType: text("context_scope_type").$type<ToolMcpGatewayContextScopeType>().notNull().default("none"),
|
||||
contextScopeId: text("context_scope_id"),
|
||||
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
approvalIssueId: uuid("approval_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
authConfig: jsonb("auth_config").$type<ToolMcpGatewayAuthConfig>().notNull().default({
|
||||
version: 1,
|
||||
bearer: {
|
||||
enabled: true,
|
||||
tokenPrefix: "pcgw",
|
||||
defaultTtlSeconds: 7_776_000,
|
||||
requireFiniteExpiry: true,
|
||||
longLivedTokenRequiresOverride: true,
|
||||
},
|
||||
oauth: {
|
||||
enabled: false,
|
||||
reservedFor: "v1_5",
|
||||
dynamicClientRegistration: false,
|
||||
authorizationCodePkce: false,
|
||||
},
|
||||
}),
|
||||
headerPolicy: jsonb("header_policy").$type<ToolMcpGatewayHeaderPolicy>().notNull().default({
|
||||
version: 1,
|
||||
callerPassthrough: { enabled: false, allowedHeaders: [] },
|
||||
staticHeaders: [],
|
||||
generatedMetadata: { enabled: false, allowedHeaders: [] },
|
||||
responseHeaders: { forwardMcpRequiredHeaders: true, forwardSafeCacheHeaders: true },
|
||||
}),
|
||||
metadataPolicy: jsonb("metadata_policy").$type<ToolMcpGatewayMetadataPolicy>().notNull().default({
|
||||
version: 1,
|
||||
forwardCompanyId: false,
|
||||
forwardGatewayId: false,
|
||||
forwardProjectId: false,
|
||||
forwardIssueId: false,
|
||||
forwardAgentId: false,
|
||||
forwardRunId: false,
|
||||
forwardCorrelationId: true,
|
||||
}),
|
||||
onDemandToolsConfig: jsonb("on_demand_tools_config").$type<ToolMcpGatewayOnDemandToolsConfig>().notNull().default({
|
||||
enabled: false,
|
||||
searchToolName: "search_tools",
|
||||
runToolName: "run_tool",
|
||||
}),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_mcp_gateways_company_idx").on(table.companyId),
|
||||
index("tool_mcp_gateways_company_status_idx").on(table.companyId, table.status),
|
||||
index("tool_mcp_gateways_profile_idx").on(table.companyId, table.profileId),
|
||||
uniqueIndex("tool_mcp_gateways_public_id_uq").on(table.gatewayPublicId),
|
||||
uniqueIndex("tool_mcp_gateways_company_slug_uq").on(table.companyId, table.slug),
|
||||
uniqueIndex("tool_mcp_gateways_company_name_uq").on(table.companyId, table.name),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolMcpGatewayTokens = pgTable(
|
||||
"tool_mcp_gateway_tokens",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
gatewayId: uuid("gateway_id").notNull().references(() => toolMcpGateways.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
tokenPrefix: text("token_prefix").notNull().default(""),
|
||||
subjectType: text("subject_type").$type<ToolMcpGatewayTokenSubjectType>().notNull().default("gateway_client"),
|
||||
subjectId: text("subject_id"),
|
||||
clientLabel: text("client_label").notNull().default(""),
|
||||
ownerNote: text("owner_note").notNull().default(""),
|
||||
allowedActions: jsonb("allowed_actions").$type<ToolMcpGatewayTokenAction[]>().notNull().default(["tools/list", "tools/call"]),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
expiryOverrideReason: text("expiry_override_reason"),
|
||||
expiryOverrideByUserId: text("expiry_override_by_user_id"),
|
||||
expiryOverrideByAgentId: uuid("expiry_override_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
expiryOverrideAt: timestamp("expiry_override_at", { withTimezone: true }),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("tool_mcp_gateway_tokens_token_hash_uq").on(table.tokenHash),
|
||||
index("tool_mcp_gateway_tokens_gateway_idx").on(table.companyId, table.gatewayId),
|
||||
index("tool_mcp_gateway_tokens_subject_idx").on(table.companyId, table.subjectType, table.subjectId),
|
||||
index("tool_mcp_gateway_tokens_company_expires_idx").on(table.companyId, table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolPolicies = pgTable(
|
||||
"tool_policies",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
policyType: text("policy_type").$type<ToolPolicyType>().notNull(),
|
||||
priority: integer("priority").notNull().default(100),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
selectors: jsonb("selectors").$type<Record<string, unknown>>().notNull().default({}),
|
||||
conditions: jsonb("conditions").$type<Record<string, unknown>>(),
|
||||
config: jsonb("config").$type<Record<string, unknown>>(),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_policies_company_enabled_idx").on(table.companyId, table.enabled),
|
||||
index("tool_policies_company_type_idx").on(table.companyId, table.policyType),
|
||||
uniqueIndex("tool_policies_company_name_uq").on(table.companyId, table.name),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolRuntimeSlots = pgTable(
|
||||
"tool_runtime_slots",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }),
|
||||
connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "cascade" }),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
ownerScopeType: text("owner_scope_type").notNull().default("connection"),
|
||||
ownerScopeId: text("owner_scope_id"),
|
||||
runtimeKind: text("runtime_kind").$type<ToolRuntimeKind>().notNull().default("local_stdio"),
|
||||
slotKey: text("slot_key").notNull(),
|
||||
status: text("status").$type<ToolRuntimeSlotStatus>().notNull().default("stopped"),
|
||||
reuseKey: text("reuse_key"),
|
||||
workspaceScope: text("workspace_scope"),
|
||||
credentialScopeHash: text("credential_scope_hash"),
|
||||
provider: text("provider"),
|
||||
providerRef: text("provider_ref"),
|
||||
processId: integer("process_id"),
|
||||
commandTemplateKey: text("command_template_key"),
|
||||
healthStatus: text("health_status").$type<ToolConnectionHealthStatus>().notNull().default("unchecked"),
|
||||
healthMessage: text("health_message"),
|
||||
lastHealthCheckAt: timestamp("last_health_check_at", { withTimezone: true }),
|
||||
lastStartedAt: timestamp("last_started_at", { withTimezone: true }),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
idleExpiresAt: timestamp("idle_expires_at", { withTimezone: true }),
|
||||
idleDeadlineAt: timestamp("idle_deadline_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_runtime_slots_company_idx").on(table.companyId),
|
||||
index("tool_runtime_slots_connection_idx").on(table.connectionId),
|
||||
index("tool_runtime_slots_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||
uniqueIndex("tool_runtime_slots_slot_key_uq").on(table.companyId, table.slotKey),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolStdioCommandTemplates = pgTable(
|
||||
"tool_stdio_command_templates",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
templateKey: text("template_key").notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").$type<"active" | "disabled">().notNull().default("active"),
|
||||
command: text("command").notNull(),
|
||||
args: jsonb("args").$type<string[]>().notNull().default([]),
|
||||
envKeys: jsonb("env_keys").$type<string[]>().notNull().default([]),
|
||||
tools: jsonb("tools").$type<Array<Record<string, unknown>>>().notNull().default([]),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_stdio_command_templates_company_idx").on(table.companyId),
|
||||
index("tool_stdio_command_templates_company_status_idx").on(table.companyId, table.status),
|
||||
uniqueIndex("tool_stdio_command_templates_company_key_uq").on(table.companyId, table.templateKey),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolGatewaySessions = pgTable(
|
||||
"tool_gateway_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }),
|
||||
gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }),
|
||||
gatewayPublicId: text("gateway_public_id"),
|
||||
clientSubjectType: text("client_subject_type").$type<ToolMcpGatewayTokenSubjectType>(),
|
||||
clientSubjectId: text("client_subject_id"),
|
||||
clientName: text("client_name"),
|
||||
mcpSessionId: text("mcp_session_id"),
|
||||
correlationId: text("correlation_id"),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("tool_gateway_sessions_token_hash_uq").on(table.tokenHash),
|
||||
index("tool_gateway_sessions_company_agent_idx").on(table.companyId, table.agentId),
|
||||
index("tool_gateway_sessions_company_expires_idx").on(table.companyId, table.expiresAt),
|
||||
index("tool_gateway_sessions_run_idx").on(table.companyId, table.runId),
|
||||
index("tool_gateway_sessions_issue_idx").on(table.companyId, table.issueId),
|
||||
index("tool_gateway_sessions_gateway_idx").on(table.companyId, table.gatewayId),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolGatewayRateLimitCounters = pgTable(
|
||||
"tool_gateway_rate_limit_counters",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
counterKey: text("counter_key").notNull(),
|
||||
windowStartAt: timestamp("window_start_at", { withTimezone: true }).notNull(),
|
||||
windowMs: integer("window_ms").notNull(),
|
||||
limit: integer("limit").notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
resetAt: timestamp("reset_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_gateway_rate_limit_counters_company_idx").on(table.companyId),
|
||||
uniqueIndex("tool_gateway_rate_limit_counters_window_uq").on(
|
||||
table.companyId,
|
||||
table.counterKey,
|
||||
table.windowStartAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolInvocations = pgTable(
|
||||
"tool_invocations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
idempotencyKey: text("idempotency_key"),
|
||||
actorType: text("actor_type").notNull().default("system"),
|
||||
actorId: text("actor_id"),
|
||||
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }),
|
||||
gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }),
|
||||
gatewayPublicId: text("gateway_public_id"),
|
||||
clientSubjectType: text("client_subject_type").$type<ToolMcpGatewayTokenSubjectType>(),
|
||||
clientSubjectId: text("client_subject_id"),
|
||||
clientName: text("client_name"),
|
||||
mcpSessionId: text("mcp_session_id"),
|
||||
correlationId: text("correlation_id"),
|
||||
applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }),
|
||||
connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "set null" }),
|
||||
catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "set null" }),
|
||||
catalogVersionHash: text("catalog_version_hash"),
|
||||
catalogSchemaHash: text("catalog_schema_hash"),
|
||||
providerType: text("provider_type"),
|
||||
applicationKey: text("application_key"),
|
||||
upstreamToolName: text("upstream_tool_name"),
|
||||
riskLevel: text("risk_level").$type<ToolRiskLevel>(),
|
||||
toolName: text("tool_name").notNull(),
|
||||
argumentsHash: text("arguments_hash"),
|
||||
argumentsSummary: jsonb("arguments_summary").$type<ToolRedactedValueSummary>(),
|
||||
policyDecision: text("policy_decision").$type<ToolPolicyDecision>(),
|
||||
matchedPolicyIds: jsonb("matched_policy_ids").$type<string[]>().notNull().default([]),
|
||||
policyExplanation: jsonb("policy_explanation").$type<Record<string, unknown>>(),
|
||||
credentialScopeSummary: jsonb("credential_scope_summary").$type<Record<string, unknown>>(),
|
||||
headerPolicySummary: jsonb("header_policy_summary").$type<Record<string, unknown>>(),
|
||||
approvalState: text("approval_state").$type<ToolInvocationApprovalState>().notNull().default("not_required"),
|
||||
status: text("status").$type<ToolInvocationStatus>().notNull().default("pending"),
|
||||
upstreamRequestId: text("upstream_request_id"),
|
||||
resultHash: text("result_hash"),
|
||||
resultSummary: jsonb("result_summary").$type<ToolRedactedValueSummary>(),
|
||||
resultSizeBytes: integer("result_size_bytes"),
|
||||
resultArtifactId: uuid("result_artifact_id"),
|
||||
errorCode: text("error_code"),
|
||||
errorMessage: text("error_message"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_invocations_company_created_idx").on(table.companyId, table.createdAt),
|
||||
index("tool_invocations_run_idx").on(table.companyId, table.runId),
|
||||
index("tool_invocations_issue_idx").on(table.companyId, table.issueId),
|
||||
index("tool_invocations_gateway_idx").on(table.companyId, table.gatewayId),
|
||||
uniqueIndex("tool_invocations_company_idempotency_uq").on(table.companyId, table.idempotencyKey),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolActionRequests = pgTable(
|
||||
"tool_action_requests",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
invocationId: uuid("invocation_id").notNull().references(() => toolInvocations.id, { onDelete: "cascade" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
interactionId: uuid("interaction_id").references(() => issueThreadInteractions.id, { onDelete: "set null" }),
|
||||
approvalId: uuid("approval_id").references(() => approvals.id, { onDelete: "set null" }),
|
||||
status: text("status").$type<ToolActionRequestStatus>().notNull().default("pending"),
|
||||
canonicalArgumentsHash: text("canonical_arguments_hash").notNull(),
|
||||
canonicalArgumentsSummary: jsonb("canonical_arguments_summary").$type<ToolRedactedValueSummary>().notNull(),
|
||||
signedArguments: text("signed_arguments"),
|
||||
previewMarkdown: text("preview_markdown"),
|
||||
requestedByAgentId: uuid("requested_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
requestedByUserId: text("requested_by_user_id"),
|
||||
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
resolvedByUserId: text("resolved_by_user_id"),
|
||||
decidedByAgentId: uuid("decided_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
decidedByUserId: text("decided_by_user_id"),
|
||||
decidedAt: timestamp("decided_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_action_requests_company_status_idx").on(table.companyId, table.status),
|
||||
index("tool_action_requests_invocation_idx").on(table.invocationId),
|
||||
index("tool_action_requests_issue_idx").on(table.companyId, table.issueId),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolCallEvents = pgTable(
|
||||
"tool_call_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
eventType: text("event_type").$type<ToolAuditEventType>().notNull(),
|
||||
actorType: text("actor_type").notNull().default("system"),
|
||||
actorId: text("actor_id"),
|
||||
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }),
|
||||
gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }),
|
||||
gatewayPublicId: text("gateway_public_id"),
|
||||
clientSubjectType: text("client_subject_type").$type<ToolMcpGatewayTokenSubjectType>(),
|
||||
clientSubjectId: text("client_subject_id"),
|
||||
clientName: text("client_name"),
|
||||
mcpSessionId: text("mcp_session_id"),
|
||||
correlationId: text("correlation_id"),
|
||||
applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }),
|
||||
connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "set null" }),
|
||||
catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "set null" }),
|
||||
invocationId: uuid("invocation_id").references(() => toolInvocations.id, { onDelete: "set null" }),
|
||||
actionRequestId: uuid("action_request_id").references(() => toolActionRequests.id, { onDelete: "set null" }),
|
||||
runtimeSlotId: uuid("runtime_slot_id").references(() => toolRuntimeSlots.id, { onDelete: "set null" }),
|
||||
toolName: text("tool_name"),
|
||||
decision: text("decision").$type<ToolPolicyDecision>(),
|
||||
matchedPolicyIds: jsonb("matched_policy_ids").$type<string[]>().notNull().default([]),
|
||||
reasonCode: text("reason_code"),
|
||||
policyExplanation: jsonb("policy_explanation").$type<Record<string, unknown>>(),
|
||||
credentialScopeSummary: jsonb("credential_scope_summary").$type<Record<string, unknown>>(),
|
||||
headerPolicySummary: jsonb("header_policy_summary").$type<Record<string, unknown>>(),
|
||||
outcome: text("outcome").$type<ToolAuditOutcome>().notNull().default("pending"),
|
||||
latencyMs: integer("latency_ms"),
|
||||
argumentsSummary: jsonb("arguments_summary").$type<ToolRedactedValueSummary>(),
|
||||
requestHash: text("request_hash"),
|
||||
requestSummary: jsonb("request_summary").$type<ToolRedactedValueSummary>(),
|
||||
resultHash: text("result_hash"),
|
||||
resultSummary: jsonb("result_summary").$type<ToolRedactedValueSummary>(),
|
||||
resultSizeBytes: integer("result_size_bytes"),
|
||||
redactionPlan: jsonb("redaction_plan").$type<Record<string, unknown>>(),
|
||||
rateLimitState: jsonb("rate_limit_state").$type<Record<string, unknown>>(),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||
errorCode: text("error_code"),
|
||||
errorMessage: text("error_message"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_call_events_company_created_idx").on(table.companyId, table.createdAt),
|
||||
index("tool_call_events_run_idx").on(table.companyId, table.runId),
|
||||
index("tool_call_events_issue_idx").on(table.companyId, table.issueId),
|
||||
index("tool_call_events_invocation_idx").on(table.invocationId),
|
||||
index("tool_call_events_gateway_idx").on(table.companyId, table.gatewayId),
|
||||
],
|
||||
);
|
||||
|
||||
export const connectionTokenIssuances = pgTable(
|
||||
"connection_token_issuances",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
applicationId: uuid("application_id").references(() => toolApplications.id, { onDelete: "set null" }),
|
||||
connectionId: uuid("connection_id").notNull().references(() => toolConnections.id, { onDelete: "cascade" }),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
runId: uuid("run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
path: text("path").$type<ConnectionTokenIssuancePath>().notNull(),
|
||||
requestedScope: jsonb("requested_scope").$type<string[]>().notNull().default([]),
|
||||
issuedScope: jsonb("issued_scope").$type<string[]>().notNull().default([]),
|
||||
ttlSeconds: integer("ttl_seconds"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
tokenHash: text("token_hash"),
|
||||
outcome: text("outcome").$type<ConnectionTokenIssuanceOutcome>().notNull(),
|
||||
errorCode: text("error_code"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("connection_token_issuances_company_created_idx").on(table.companyId, table.createdAt),
|
||||
index("connection_token_issuances_connection_created_idx").on(table.companyId, table.connectionId, table.createdAt),
|
||||
index("connection_token_issuances_agent_connection_idx").on(table.companyId, table.agentId, table.connectionId, table.createdAt),
|
||||
index("connection_token_issuances_run_idx").on(table.companyId, table.runId),
|
||||
sql`CONSTRAINT connection_token_issuances_path_check CHECK (${table.path} IN ('exchange', 'oauth_access', 'static'))`,
|
||||
sql`CONSTRAINT connection_token_issuances_outcome_check CHECK (${table.outcome} IN ('success', 'denied', 'rate_limited', 'use_env_lease', 'upstream_error', 'failure'))`,
|
||||
sql`CONSTRAINT connection_token_issuances_ttl_bounds CHECK (${table.ttlSeconds} IS NULL OR (${table.ttlSeconds} >= 1 AND ${table.ttlSeconds} <= 900))`,
|
||||
sql`CONSTRAINT connection_token_issuances_token_hash_format CHECK (${table.tokenHash} IS NULL OR ${table.tokenHash} ~ '^[a-f0-9]{64}$')`,
|
||||
],
|
||||
);
|
||||
|
||||
export const toolRateLimitCounters = pgTable(
|
||||
"tool_rate_limit_counters",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
policyId: uuid("policy_id").notNull().references(() => toolPolicies.id, { onDelete: "cascade" }),
|
||||
counterKey: text("counter_key").notNull(),
|
||||
scopeType: text("scope_type").notNull(),
|
||||
scopeId: text("scope_id").notNull(),
|
||||
windowKind: text("window_kind").$type<ToolRateLimitWindowKind>().notNull(),
|
||||
windowStartAt: timestamp("window_start_at", { withTimezone: true }).notNull(),
|
||||
limit: integer("limit").notNull(),
|
||||
remaining: integer("remaining").notNull(),
|
||||
resetAt: timestamp("reset_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_rate_limit_counters_company_idx").on(table.companyId),
|
||||
uniqueIndex("tool_rate_limit_counters_window_uq").on(
|
||||
table.companyId,
|
||||
table.policyId,
|
||||
table.counterKey,
|
||||
table.windowKind,
|
||||
table.windowStartAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const toolRuntimeMetricCounters = pgTable(
|
||||
"tool_runtime_metric_counters",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
metric: text("metric").notNull(),
|
||||
bucketStartAt: timestamp("bucket_start_at", { withTimezone: true }).notNull(),
|
||||
count: integer("count").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_runtime_metric_counters_company_metric_idx").on(table.companyId, table.metric, table.bucketStartAt),
|
||||
uniqueIndex("tool_runtime_metric_counters_bucket_uq").on(table.companyId, table.metric, table.bucketStartAt),
|
||||
sql`CONSTRAINT tool_runtime_metric_counters_count_nonnegative CHECK (${table.count} >= 0)`,
|
||||
],
|
||||
);
|
||||
|
||||
export const toolAccessAuditEvents = pgTable(
|
||||
"tool_access_audit_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
gatewayId: uuid("gateway_id").references(() => toolMcpGateways.id, { onDelete: "set null" }),
|
||||
gatewayTokenId: uuid("gateway_token_id").references(() => toolMcpGatewayTokens.id, { onDelete: "set null" }),
|
||||
gatewayPublicId: text("gateway_public_id"),
|
||||
clientName: text("client_name"),
|
||||
correlationId: text("correlation_id"),
|
||||
connectionId: uuid("connection_id").references(() => toolConnections.id, { onDelete: "set null" }),
|
||||
catalogEntryId: uuid("catalog_entry_id").references(() => toolCatalogEntries.id, { onDelete: "set null" }),
|
||||
actorType: text("actor_type").notNull().default("system"),
|
||||
actorId: text("actor_id"),
|
||||
action: text("action").notNull(),
|
||||
outcome: text("outcome").notNull(),
|
||||
reasonCode: text("reason_code"),
|
||||
details: jsonb("details").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index("tool_access_audit_company_created_idx").on(table.companyId, table.createdAt),
|
||||
index("tool_access_audit_connection_idx").on(table.connectionId),
|
||||
index("tool_access_audit_gateway_idx").on(table.companyId, table.gatewayId),
|
||||
],
|
||||
);
|
||||
|
|
@ -65,10 +65,11 @@ const plugin = definePlugin({
|
|||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
|
||||
// Expose the current plugin config so UI components can read operator
|
||||
// settings from the canonical instance config store.
|
||||
ctx.data.register("plugin-config", async () => {
|
||||
const config = await ctx.config.get();
|
||||
// Expose the current company-scoped plugin config so UI components can read
|
||||
// operator settings from the canonical config store.
|
||||
ctx.data.register("plugin-config", async (params) => {
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const config = companyId ? await ctx.config.get(companyId) : null;
|
||||
return {
|
||||
showFilesInSidebar: config?.showFilesInSidebar === true,
|
||||
commentAnnotationMode: config?.commentAnnotationMode ?? "both",
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ function hostFetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
});
|
||||
}
|
||||
|
||||
function useSettingsConfig() {
|
||||
function useSettingsConfig(companyId: string | null) {
|
||||
const [configJson, setConfigJson] = useState<Record<string, unknown>>({ ...DEFAULT_CONFIG });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -430,8 +430,15 @@ function useSettingsConfig() {
|
|||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!companyId) {
|
||||
setLoading(false);
|
||||
setError("Select a company before loading plugin config.");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setLoading(true);
|
||||
hostFetchJson<{ configJson?: Record<string, unknown> | null } | null>(`/api/plugins/${PLUGIN_ID}/config`)
|
||||
hostFetchJson<{ configJson?: Record<string, unknown> | null } | null>(`/api/plugins/${PLUGIN_ID}/config?companyId=${encodeURIComponent(companyId)}`)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setConfigJson({ ...DEFAULT_CONFIG, ...(result?.configJson ?? {}) });
|
||||
|
|
@ -447,14 +454,15 @@ function useSettingsConfig() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [companyId]);
|
||||
|
||||
async function save(nextConfig: Record<string, unknown>) {
|
||||
if (!companyId) throw new Error("Select a company before saving plugin config.");
|
||||
setSaving(true);
|
||||
try {
|
||||
await hostFetchJson(`/api/plugins/${PLUGIN_ID}/config`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ configJson: nextConfig }),
|
||||
body: JSON.stringify({ companyId, configJson: nextConfig }),
|
||||
});
|
||||
setConfigJson(nextConfig);
|
||||
setError(null);
|
||||
|
|
@ -2103,7 +2111,7 @@ export function KitchenSinkPage({ context }: PluginPageProps) {
|
|||
}
|
||||
|
||||
export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
|
||||
const { configJson, setConfigJson, loading, saving, error, save } = useSettingsConfig();
|
||||
const { configJson, setConfigJson, loading, saving, error, save } = useSettingsConfig(context.companyId);
|
||||
const [savedMessage, setSavedMessage] = useState<string | null>(null);
|
||||
|
||||
function setField(key: string, value: unknown) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
definePlugin,
|
||||
runWorker,
|
||||
type PaperclipPlugin,
|
||||
type EnvSecretRefBinding,
|
||||
type PluginContext,
|
||||
type PluginEntityQuery,
|
||||
type PluginEvent,
|
||||
|
|
@ -41,7 +42,7 @@ type KitchenSinkConfig = {
|
|||
showCommentContextMenuItem?: boolean;
|
||||
enableWorkspaceDemos?: boolean;
|
||||
enableProcessDemos?: boolean;
|
||||
secretRefExample?: string;
|
||||
secretRefExample?: string | EnvSecretRefBinding;
|
||||
httpDemoUrl?: string;
|
||||
allowedCommands?: string[];
|
||||
workspaceScratchFile?: string;
|
||||
|
|
@ -91,14 +92,20 @@ function pushRecord(record: Omit<DemoRecord, "id" | "createdAt">): DemoRecord {
|
|||
return next;
|
||||
}
|
||||
|
||||
async function getConfig(ctx: PluginContext): Promise<KitchenSinkConfig> {
|
||||
const config = await ctx.config.get();
|
||||
async function getConfig(ctx: PluginContext, companyId?: string): Promise<KitchenSinkConfig> {
|
||||
const config = await ctx.config.get(companyId);
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...(config as KitchenSinkConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function isSecretRefBinding(value: unknown): value is EnvSecretRefBinding {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
&& (value as { type?: unknown }).type === "secret_ref"
|
||||
&& typeof (value as { secretId?: unknown }).secretId === "string";
|
||||
}
|
||||
|
||||
async function writeInstanceState(ctx: PluginContext, stateKey: string, value: unknown): Promise<void> {
|
||||
await ctx.state.set({ scopeKind: "instance", stateKey }, value);
|
||||
}
|
||||
|
|
@ -248,13 +255,14 @@ function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] {
|
|||
}
|
||||
|
||||
async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
||||
ctx.data.register("plugin-config", async () => {
|
||||
return await getConfig(ctx);
|
||||
ctx.data.register("plugin-config", async (params) => {
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : undefined;
|
||||
return await getConfig(ctx, companyId);
|
||||
});
|
||||
|
||||
ctx.data.register("overview", async (params) => {
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const config = await getConfig(ctx);
|
||||
const config = companyId ? await getConfig(ctx, companyId) : DEFAULT_CONFIG;
|
||||
const companies = await ctx.companies.list({ limit: 200, offset: 0 });
|
||||
const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : [];
|
||||
const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : [];
|
||||
|
|
@ -583,7 +591,8 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
|||
});
|
||||
|
||||
ctx.actions.register("http-fetch", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
const url = typeof params.url === "string" && params.url.length > 0
|
||||
? params.url
|
||||
: config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl;
|
||||
|
|
@ -607,29 +616,32 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
|||
});
|
||||
|
||||
ctx.actions.register("resolve-secret", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const secretRef = typeof params.secretRef === "string" && params.secretRef.length > 0
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
const secretRef = isSecretRefBinding(params.secretRef)
|
||||
? params.secretRef
|
||||
: config.secretRefExample || "";
|
||||
: isSecretRefBinding(config.secretRefExample)
|
||||
? config.secretRefExample
|
||||
: null;
|
||||
if (!secretRef) {
|
||||
throw new Error("No secret reference configured");
|
||||
}
|
||||
const resolved = await ctx.secrets.resolve(secretRef);
|
||||
const resolved = await ctx.secrets.resolve(secretRef, { companyId, configPath: "secretRefExample" });
|
||||
pushRecord({
|
||||
level: "info",
|
||||
source: "secrets",
|
||||
message: `Resolved secret reference ${secretRef}`,
|
||||
message: `Resolved secret reference ${secretRef.secretId}`,
|
||||
});
|
||||
return {
|
||||
secretRef,
|
||||
secretRef: secretRef.secretId,
|
||||
resolvedLength: resolved.length,
|
||||
preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "",
|
||||
};
|
||||
});
|
||||
|
||||
ctx.actions.register("run-process", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
||||
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
||||
const commandKey = typeof params.commandKey === "string" ? params.commandKey : "pwd";
|
||||
|
|
@ -638,11 +650,11 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
|||
});
|
||||
|
||||
ctx.actions.register("read-workspace-file", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
if (!config.enableWorkspaceDemos) {
|
||||
throw new Error("Workspace demos are disabled in plugin settings");
|
||||
}
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
||||
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
||||
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
|
||||
|
|
@ -660,11 +672,11 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
|||
});
|
||||
|
||||
ctx.actions.register("write-workspace-scratch", async (params) => {
|
||||
const config = await getConfig(ctx);
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const config = await getConfig(ctx, companyId);
|
||||
if (!config.enableWorkspaceDemos) {
|
||||
throw new Error("Workspace demos are disabled in plugin settings");
|
||||
}
|
||||
const companyId = getCurrentCompanyId(params);
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
||||
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
||||
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
|
||||
|
|
@ -965,8 +977,7 @@ const plugin: PaperclipPlugin = definePlugin({
|
|||
},
|
||||
|
||||
async onHealth(): Promise<PluginHealthDiagnostics> {
|
||||
const ctx = currentContext;
|
||||
const config = ctx ? await getConfig(ctx) : DEFAULT_CONFIG;
|
||||
const config: KitchenSinkConfig = DEFAULT_CONFIG;
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Kitchen Sink plugin ready",
|
||||
|
|
|
|||
|
|
@ -639,11 +639,11 @@ function protectDistillationSourceBody(input: {
|
|||
|
||||
async function resolvePaperclipDistillationLimits(
|
||||
ctx: PluginContext,
|
||||
input: Pick<PaperclipSourceBundleInput, "maxCharacters" | "maxCharactersPerSource" | "routineRun">,
|
||||
input: Pick<PaperclipSourceBundleInput, "companyId" | "maxCharacters" | "maxCharactersPerSource" | "routineRun">,
|
||||
): Promise<PaperclipDistillationLimits> {
|
||||
assertRequestedCharacterLimit("maxCharacters", input.maxCharacters, DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS);
|
||||
assertRequestedCharacterLimit("maxCharactersPerSource", input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS);
|
||||
const config = await ctx.config.get() as Record<string, unknown>;
|
||||
const config = await ctx.config.get(input.companyId) as Record<string, unknown>;
|
||||
const maxCharactersPerSource = Math.min(
|
||||
normalizeBundleLimit(input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS),
|
||||
normalizeBundleLimit(config.maxPaperclipIssueSourceCharacters, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS),
|
||||
|
|
@ -686,8 +686,8 @@ function estimateSourceCostCents(characters: number, costCentsPerThousandSourceC
|
|||
return Math.ceil((characters / 1000) * costCentsPerThousandSourceCharacters);
|
||||
}
|
||||
|
||||
async function assertSourceWithinConfiguredLimit(ctx: PluginContext, contents: string) {
|
||||
const config = await ctx.config.get();
|
||||
async function assertSourceWithinConfiguredLimit(ctx: PluginContext, companyId: string, contents: string) {
|
||||
const config = await ctx.config.get(companyId);
|
||||
const maxSourceBytes = normalizeMaxSourceBytes(config.maxSourceBytes);
|
||||
const sourceBytes = byteLength(contents);
|
||||
if (sourceBytes > maxSourceBytes) {
|
||||
|
|
@ -1866,7 +1866,7 @@ export async function captureWikiSource(ctx: PluginContext, input: CaptureSource
|
|||
const wikiId = normalizeWikiId(input.wikiId);
|
||||
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
||||
const title = stringField(input.title) ?? "Untitled source";
|
||||
await assertSourceWithinConfiguredLimit(ctx, input.contents);
|
||||
await assertSourceWithinConfiguredLimit(ctx, input.companyId, input.contents);
|
||||
const hash = contentHash(input.contents);
|
||||
const rawPath = input.rawPath
|
||||
? assertRawPath(input.rawPath)
|
||||
|
|
@ -3308,11 +3308,11 @@ async function upsertPageBinding(ctx: PluginContext, input: {
|
|||
);
|
||||
}
|
||||
|
||||
async function autoApplyEnabled(ctx: PluginContext, requested: boolean | undefined): Promise<boolean> {
|
||||
async function autoApplyEnabled(ctx: PluginContext, companyId: string, requested: boolean | undefined): Promise<boolean> {
|
||||
if (getDistillationAutoApplyRestriction().autoApplyRestriction) {
|
||||
return false;
|
||||
}
|
||||
const config = await ctx.config.get();
|
||||
const config = await ctx.config.get(companyId);
|
||||
const configured = (config as { autoApplyIngestPatches?: unknown }).autoApplyIngestPatches !== false;
|
||||
return configured && requested !== false;
|
||||
}
|
||||
|
|
@ -3462,7 +3462,7 @@ export async function distillPaperclipProjectPage(ctx: PluginContext, input: Pap
|
|||
}
|
||||
|
||||
const autoApplyRestriction = getDistillationAutoApplyRestriction();
|
||||
const canAutoApply = await autoApplyEnabled(ctx, input.autoApply);
|
||||
const canAutoApply = await autoApplyEnabled(ctx, input.companyId, input.autoApply);
|
||||
if (!canAutoApply || reviewRequired) {
|
||||
const autoApplyWarning =
|
||||
autoApplyRestriction.autoApplyRestriction
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@
|
|||
*
|
||||
* // Subscribe to events
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* const config = await ctx.config.get();
|
||||
* const companyId = event.companyId;
|
||||
* const config = await ctx.config.get(companyId);
|
||||
* const apiKey = await ctx.secrets.resolve(config.apiKeyRef, { companyId, configPath: "apiKeyRef" });
|
||||
* await ctx.http.fetch(`https://api.linear.app/...`, {
|
||||
* method: "POST",
|
||||
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
|
||||
* headers: { Authorization: `Bearer ${apiKey}` },
|
||||
* body: JSON.stringify({ title: event.payload.title }),
|
||||
* });
|
||||
* });
|
||||
|
|
@ -203,8 +205,8 @@ export interface PluginDefinition {
|
|||
onHealth?(): Promise<PluginHealthDiagnostics>;
|
||||
|
||||
/**
|
||||
* Called when the operator updates the plugin's instance configuration at
|
||||
* runtime, without restarting the worker.
|
||||
* Called when the operator updates this plugin's company-scoped configuration
|
||||
* at runtime, without restarting the worker.
|
||||
*
|
||||
* If not implemented, the host restarts the worker to apply the new config.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -100,7 +100,10 @@ export class InvocationScopeDeniedError extends Error {
|
|||
export interface HostServices {
|
||||
/** Provides `config.get`. */
|
||||
config: {
|
||||
get(): Promise<Record<string, unknown>>;
|
||||
get(
|
||||
params: WorkerToHostMethods["config.get"][0],
|
||||
context?: WorkerHostCallContext,
|
||||
): Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Provides trusted company-scoped local folder helpers. */
|
||||
|
|
@ -147,7 +150,10 @@ export interface HostServices {
|
|||
|
||||
/** Provides `secrets.resolve`. */
|
||||
secrets: {
|
||||
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
|
||||
resolve(
|
||||
params: WorkerToHostMethods["secrets.resolve"][0],
|
||||
context?: WorkerHostCallContext,
|
||||
): Promise<string>;
|
||||
};
|
||||
|
||||
/** Provides `activity.log`. */
|
||||
|
|
@ -570,10 +576,12 @@ export function createHostClientHandlers(
|
|||
}
|
||||
|
||||
const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
|
||||
if (!allowedCompanyId) return;
|
||||
|
||||
if (requested.kind === "all") {
|
||||
if (method === "companies.list") return;
|
||||
if (!allowedCompanyId) {
|
||||
throw new InvocationScopeDeniedError(pluginId, method, "company context is required");
|
||||
}
|
||||
throw new InvocationScopeDeniedError(
|
||||
pluginId,
|
||||
method,
|
||||
|
|
@ -581,6 +589,10 @@ export function createHostClientHandlers(
|
|||
);
|
||||
}
|
||||
|
||||
if (!allowedCompanyId) {
|
||||
throw new InvocationScopeDeniedError(pluginId, method, "company context is required");
|
||||
}
|
||||
|
||||
if (requested.companyId !== allowedCompanyId) {
|
||||
throw new InvocationScopeDeniedError(
|
||||
pluginId,
|
||||
|
|
@ -590,6 +602,40 @@ export function createHostClientHandlers(
|
|||
}
|
||||
}
|
||||
|
||||
function resolveRequiredCompanyId(
|
||||
method: WorkerToHostMethodName,
|
||||
params: unknown,
|
||||
context?: WorkerHostCallContext,
|
||||
): string {
|
||||
if (context?.invalidInvocationScope) {
|
||||
throw new InvocationScopeDeniedError(
|
||||
pluginId,
|
||||
method,
|
||||
"the worker referenced a missing, expired, or unknown invocation scope",
|
||||
);
|
||||
}
|
||||
|
||||
const requested = requestedCompanyScope(method, params);
|
||||
const scopedCompanyId = readNonEmptyString(context?.invocationScope?.companyId);
|
||||
if (requested.kind === "single") {
|
||||
if (!scopedCompanyId) {
|
||||
throw new InvocationScopeDeniedError(pluginId, method, "company context is required");
|
||||
}
|
||||
if (requested.companyId !== scopedCompanyId) {
|
||||
throw new InvocationScopeDeniedError(
|
||||
pluginId,
|
||||
method,
|
||||
`requested company "${requested.companyId}" but the current invocation is scoped to company "${scopedCompanyId}"`,
|
||||
);
|
||||
}
|
||||
return scopedCompanyId;
|
||||
}
|
||||
|
||||
if (scopedCompanyId) return scopedCompanyId;
|
||||
|
||||
throw new InvocationScopeDeniedError(pluginId, method, "company context is required");
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the plugin has the required capability for a method.
|
||||
* Throws `CapabilityDeniedError` if the capability is missing.
|
||||
|
|
@ -627,8 +673,9 @@ export function createHostClientHandlers(
|
|||
|
||||
return {
|
||||
// Config
|
||||
"config.get": gated("config.get", async () => {
|
||||
return services.config.get();
|
||||
"config.get": gated("config.get", async (params, context) => {
|
||||
const companyId = resolveRequiredCompanyId("config.get", params, context);
|
||||
return services.config.get({ ...params, companyId }, context);
|
||||
}),
|
||||
|
||||
"localFolders.declarations": gated("localFolders.declarations", async (params) => {
|
||||
|
|
@ -696,8 +743,9 @@ export function createHostClientHandlers(
|
|||
}),
|
||||
|
||||
// Secrets
|
||||
"secrets.resolve": gated("secrets.resolve", async (params) => {
|
||||
return services.secrets.resolve(params);
|
||||
"secrets.resolve": gated("secrets.resolve", async (params, context) => {
|
||||
const companyId = resolveRequiredCompanyId("secrets.resolve", params, context);
|
||||
return services.secrets.resolve({ ...params, companyId }, context);
|
||||
}),
|
||||
|
||||
// Activity
|
||||
|
|
|
|||
|
|
@ -304,6 +304,7 @@ export type {
|
|||
PluginDatabaseClient,
|
||||
HumanCompanyMembershipRole,
|
||||
MembershipStatus,
|
||||
EnvSecretRefBinding,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import type {
|
|||
ExternalObjectLivenessState,
|
||||
ExternalObjectMentionConfidence,
|
||||
ExternalObjectMentionSourceKind,
|
||||
EnvSecretRefBinding,
|
||||
} from "@paperclipai/shared";
|
||||
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
||||
|
||||
|
|
@ -302,7 +303,7 @@ export interface WorkerHostCallContext {
|
|||
export interface InitializeParams {
|
||||
/** Full plugin manifest snapshot. */
|
||||
manifest: PaperclipPluginManifestV1;
|
||||
/** Resolved operator configuration (validated against `instanceConfigSchema`). */
|
||||
/** Bootstrap configuration. Company-scoped config is read via `ctx.config.get(companyId)`. */
|
||||
config: Record<string, unknown>;
|
||||
/** Instance-level metadata. */
|
||||
instanceInfo: {
|
||||
|
|
@ -333,8 +334,10 @@ export interface InitializeResult {
|
|||
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
|
||||
*/
|
||||
export interface ConfigChangedParams {
|
||||
/** The newly resolved configuration. */
|
||||
/** The newly resolved company-scoped configuration. */
|
||||
config: Record<string, unknown>;
|
||||
/** Company whose plugin config changed. */
|
||||
companyId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -934,7 +937,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
|||
*/
|
||||
export interface WorkerToHostMethods {
|
||||
// Config
|
||||
"config.get": [params: Record<string, never>, result: Record<string, unknown>];
|
||||
"config.get": [params: { companyId?: string }, result: Record<string, unknown>];
|
||||
|
||||
// Trusted local folders
|
||||
"localFolders.declarations": [
|
||||
|
|
@ -1071,7 +1074,7 @@ export interface WorkerToHostMethods {
|
|||
|
||||
// Secrets
|
||||
"secrets.resolve": [
|
||||
params: { secretRef: string },
|
||||
params: { secretRef: string | EnvSecretRefBinding; companyId?: string; configPath?: string },
|
||||
result: string,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export interface TestHarnessOptions {
|
|||
manifest: PaperclipPluginManifestV1;
|
||||
/** Optional capability override. Defaults to `manifest.capabilities`. */
|
||||
capabilities?: PluginCapability[];
|
||||
/** Initial config returned by `ctx.config.get()`. */
|
||||
/** Initial config returned by `ctx.config.get(companyId)`. */
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import type {
|
|||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
EnvSecretRefBinding,
|
||||
} from "@paperclipai/shared";
|
||||
import type { PluginPerformActionContext } from "./protocol.js";
|
||||
|
||||
|
|
@ -138,6 +139,7 @@ export type {
|
|||
PermissionKey,
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
EnvSecretRefBinding,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -426,11 +428,11 @@ export interface PluginExecutionWorkspaceMetadata {
|
|||
*/
|
||||
export interface PluginConfigClient {
|
||||
/**
|
||||
* Returns the resolved operator configuration for this plugin instance.
|
||||
* Values are validated against the plugin's `instanceConfigSchema` by the
|
||||
* host before being passed to the worker.
|
||||
* Returns the resolved operator configuration for this plugin in a company.
|
||||
* When called during a host-scoped invocation, the host may derive the
|
||||
* companyId; otherwise callers must pass it explicitly.
|
||||
*/
|
||||
get(): Promise<Record<string, unknown>>;
|
||||
get(companyId?: string): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderProblem {
|
||||
|
|
@ -642,9 +644,9 @@ export interface PluginHttpClient {
|
|||
*
|
||||
* Requires `secrets.read-ref` capability.
|
||||
*
|
||||
* Plugins store secret *references* in their config (e.g. a secret name).
|
||||
* This client resolves the reference through the Paperclip secret provider
|
||||
* system and returns the resolved value at execution time.
|
||||
* Plugins store shared `{ type: "secret_ref", secretId, version? }` bindings in
|
||||
* company-scoped config. This client resolves a bound ref through the
|
||||
* Paperclip secret provider system at execution time.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §22 — Secrets
|
||||
*/
|
||||
|
|
@ -652,16 +654,19 @@ export interface PluginSecretsClient {
|
|||
/**
|
||||
* Resolve a secret reference to its current value.
|
||||
*
|
||||
* The reference is a string identifier pointing to a secret configured
|
||||
* in the Paperclip secret provider (e.g. `"MY_API_KEY"`).
|
||||
* The reference must be the shared `secret_ref` object shape from plugin
|
||||
* config. Legacy string UUID references fail closed.
|
||||
*
|
||||
* Secret values are resolved at call time and must never be cached or
|
||||
* written to logs, config, or other persistent storage.
|
||||
*
|
||||
* @param secretRef - The secret reference string from plugin config
|
||||
* @param secretRef - The secret reference object from plugin config
|
||||
* @returns The resolved secret value
|
||||
*/
|
||||
resolve(secretRef: string): Promise<string>;
|
||||
resolve(
|
||||
secretRef: string | EnvSecretRefBinding,
|
||||
options?: { companyId?: string; configPath?: string },
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -422,8 +422,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
},
|
||||
|
||||
config: {
|
||||
async get() {
|
||||
return callHost("config.get", {} as Record<string, never>);
|
||||
async get(companyId?: string) {
|
||||
return callHost("config.get", companyId ? { companyId } : {});
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -573,8 +573,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
},
|
||||
|
||||
secrets: {
|
||||
async resolve(secretRef: string): Promise<string> {
|
||||
return callHost("secrets.resolve", { secretRef });
|
||||
async resolve(secretRef, options = {}): Promise<string> {
|
||||
return callHost("secrets.resolve", {
|
||||
secretRef,
|
||||
companyId: options.companyId,
|
||||
configPath: options.configPath,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,65 @@ import {
|
|||
import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js";
|
||||
|
||||
describe("createHostClientHandlers invocation company scope", () => {
|
||||
it("rejects worker-selected config and secret company ids without a host invocation scope", async () => {
|
||||
const configGet = vi.fn(async () => ({ apiKeyRef: "unreachable" }));
|
||||
const secretsResolve = vi.fn(async () => "unreachable");
|
||||
const services = {
|
||||
config: { get: configGet },
|
||||
secrets: { resolve: secretsResolve },
|
||||
} as unknown as HostServices;
|
||||
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "paperclip.test",
|
||||
capabilities: ["secrets.read-ref"],
|
||||
services,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers["config.get"]({ companyId: "company-a" }),
|
||||
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
await expect(
|
||||
handlers["secrets.resolve"]({
|
||||
companyId: "company-a",
|
||||
secretRef: { type: "secret_ref", secretId: "secret-a" },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvocationScopeDeniedError);
|
||||
expect(configGet).not.toHaveBeenCalled();
|
||||
expect(secretsResolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicit config and secret company ids only when they match the host invocation scope", async () => {
|
||||
const configGet = vi.fn(async () => ({ apiKeyRef: "ref" }));
|
||||
const secretsResolve = vi.fn(async () => "resolved");
|
||||
const services = {
|
||||
config: { get: configGet },
|
||||
secrets: { resolve: secretsResolve },
|
||||
} as unknown as HostServices;
|
||||
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "paperclip.test",
|
||||
capabilities: ["secrets.read-ref"],
|
||||
services,
|
||||
});
|
||||
const context = { invocationScope: { companyId: "company-a" } };
|
||||
|
||||
await expect(
|
||||
handlers["config.get"]({ companyId: "company-a" }, context),
|
||||
).resolves.toEqual({ apiKeyRef: "ref" });
|
||||
await expect(
|
||||
handlers["secrets.resolve"]({
|
||||
companyId: "company-a",
|
||||
secretRef: { type: "secret_ref", secretId: "secret-a" },
|
||||
}, context),
|
||||
).resolves.toBe("resolved");
|
||||
|
||||
expect(configGet).toHaveBeenCalledWith({ companyId: "company-a" }, context);
|
||||
expect(secretsResolve).toHaveBeenCalledWith({
|
||||
companyId: "company-a",
|
||||
secretRef: { type: "secret_ref", secretId: "secret-a" },
|
||||
}, context);
|
||||
});
|
||||
|
||||
it("rejects company-scoped host calls outside the current invocation company", async () => {
|
||||
const projectsList = vi.fn(async () => []);
|
||||
const services = {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,24 @@ export const API = {
|
|||
goals: `${API_PREFIX}/goals`,
|
||||
approvals: `${API_PREFIX}/approvals`,
|
||||
secrets: `${API_PREFIX}/secrets`,
|
||||
tools: `${API_PREFIX}/companies/:companyId/tools`,
|
||||
toolExamples: `${API_PREFIX}/companies/:companyId/tools/examples`,
|
||||
toolApplications: `${API_PREFIX}/companies/:companyId/tools/applications`,
|
||||
toolConnections: `${API_PREFIX}/companies/:companyId/tools/connections`,
|
||||
toolCatalog: `${API_PREFIX}/companies/:companyId/tools/catalog`,
|
||||
toolProfiles: `${API_PREFIX}/companies/:companyId/tools/profiles`,
|
||||
toolPolicies: `${API_PREFIX}/companies/:companyId/tools/policies`,
|
||||
toolAudit: `${API_PREFIX}/companies/:companyId/tools/audit`,
|
||||
toolRuntimeSlots: `${API_PREFIX}/companies/:companyId/tools/runtime-slots`,
|
||||
toolRuntimeSlotStop: `${API_PREFIX}/companies/:companyId/tools/runtime-slots/:id/stop`,
|
||||
toolRuntimeSlotRestart: `${API_PREFIX}/companies/:companyId/tools/runtime-slots/:id/restart`,
|
||||
toolRuntimeHealth: `${API_PREFIX}/companies/:companyId/tools/runtime-health`,
|
||||
toolGateway: `${API_PREFIX}/tool-gateway`,
|
||||
smokeLab: `${API_PREFIX}/companies/:companyId/smoke-lab`,
|
||||
smokeLabServices: `${API_PREFIX}/companies/:companyId/smoke-lab/services`,
|
||||
smokeLabInstallFixtures: `${API_PREFIX}/companies/:companyId/smoke-lab/install-fixtures`,
|
||||
smokeLabRuns: `${API_PREFIX}/companies/:companyId/smoke-lab/runs`,
|
||||
smokeLabRunSteps: `${API_PREFIX}/companies/:companyId/smoke-lab/runs/:runId/steps`,
|
||||
userSecretDefinitions: `${API_PREFIX}/companies/:companyId/user-secret-definitions`,
|
||||
userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`,
|
||||
userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`,
|
||||
|
|
|
|||
|
|
@ -659,6 +659,7 @@ export const SECRET_BINDING_TARGET_TYPES = [
|
|||
"plugin",
|
||||
"issue",
|
||||
"run",
|
||||
"tool_connection",
|
||||
"system",
|
||||
] as const;
|
||||
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
|
||||
|
|
@ -674,6 +675,55 @@ export const SECRET_ACCESS_OUTCOMES = [
|
|||
] as const;
|
||||
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
|
||||
|
||||
export const SECRET_PROJECTION_CLASSES = ["unclassified", "class_3_static_lease"] as const;
|
||||
export type SecretProjectionClass = (typeof SECRET_PROJECTION_CLASSES)[number];
|
||||
|
||||
export const CLASS3_STATIC_LEASE_ALLOWLIST = [
|
||||
{
|
||||
key: "slack.bot_token",
|
||||
label: "Slack bot token",
|
||||
targetType: "agent",
|
||||
configPath: "env.SLACK_BOT_TOKEN",
|
||||
envKey: "SLACK_BOT_TOKEN",
|
||||
},
|
||||
{
|
||||
key: "slack.bot_token",
|
||||
label: "Slack bot token",
|
||||
targetType: "routine",
|
||||
configPath: "env.SLACK_BOT_TOKEN",
|
||||
envKey: "SLACK_BOT_TOKEN",
|
||||
},
|
||||
{
|
||||
key: "slack.bot_token",
|
||||
label: "Slack bot token governance connection",
|
||||
targetType: "tool_connection",
|
||||
configPath: "credentials.bot_token",
|
||||
envKey: "SLACK_BOT_TOKEN",
|
||||
},
|
||||
{
|
||||
key: "discord.bot_token",
|
||||
label: "Discord bot token",
|
||||
targetType: "agent",
|
||||
configPath: "env.DISCORD_BOT_TOKEN",
|
||||
envKey: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
{
|
||||
key: "discord.bot_token",
|
||||
label: "Discord bot token",
|
||||
targetType: "routine",
|
||||
configPath: "env.DISCORD_BOT_TOKEN",
|
||||
envKey: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
{
|
||||
key: "discord.bot_token",
|
||||
label: "Discord bot token governance connection",
|
||||
targetType: "tool_connection",
|
||||
configPath: "credentials.bot_token",
|
||||
envKey: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
] as const;
|
||||
export type Class3StaticLeaseAllowlistKey = (typeof CLASS3_STATIC_LEASE_ALLOWLIST)[number]["key"];
|
||||
|
||||
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
||||
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
|
||||
|
||||
|
|
@ -860,6 +910,12 @@ export const PERMISSION_KEYS = [
|
|||
"skills:create",
|
||||
"skills:suggest-changes",
|
||||
"environments:manage",
|
||||
"tools:admin",
|
||||
"tools:manage_connections",
|
||||
"tools:manage_profiles",
|
||||
"tools:view_audit",
|
||||
"tools:use",
|
||||
"tools:manage_runtime",
|
||||
"users:invite",
|
||||
"users:manage_permissions",
|
||||
"tasks:assign",
|
||||
|
|
@ -870,6 +926,235 @@ export const PERMISSION_KEYS = [
|
|||
] as const;
|
||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
export const TOOL_APPLICATION_TYPES = ["mcp_http", "mcp_stdio", "paperclip_plugin", "a2a"] as const;
|
||||
export type ToolApplicationType = (typeof TOOL_APPLICATION_TYPES)[number];
|
||||
|
||||
export const TOOL_APPLICATION_STATUSES = ["draft", "active", "disabled", "archived"] as const;
|
||||
export type ToolApplicationStatus = (typeof TOOL_APPLICATION_STATUSES)[number];
|
||||
|
||||
export const TOOL_CONNECTION_KINDS = ["managed"] as const;
|
||||
export type ToolConnectionKind = (typeof TOOL_CONNECTION_KINDS)[number];
|
||||
|
||||
export const TOOL_CONNECTION_HEALTH_STATUSES = [
|
||||
"unknown",
|
||||
"healthy",
|
||||
"degraded",
|
||||
"failed",
|
||||
"unchecked",
|
||||
"ok",
|
||||
"error",
|
||||
"missing_secret",
|
||||
] as const;
|
||||
export type ToolConnectionHealthStatus = (typeof TOOL_CONNECTION_HEALTH_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Health states that mean an app needs the user's attention (a bad/missing key
|
||||
* or a degraded connection). Single source of truth shared by the needs-
|
||||
* attention aggregation and the prosumer Apps surfaces so their counts agree.
|
||||
*/
|
||||
export const TOOL_CONNECTION_ATTENTION_HEALTH_STATUSES: readonly ToolConnectionHealthStatus[] = [
|
||||
"degraded",
|
||||
"failed",
|
||||
"error",
|
||||
"missing_secret",
|
||||
];
|
||||
|
||||
export function isToolConnectionAttentionHealth(status: ToolConnectionHealthStatus): boolean {
|
||||
return TOOL_CONNECTION_ATTENTION_HEALTH_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
export const TOOL_CATALOG_ENTRY_KINDS = ["tool", "resource", "prompt"] as const;
|
||||
export type ToolCatalogEntryKind = (typeof TOOL_CATALOG_ENTRY_KINDS)[number];
|
||||
|
||||
export const TOOL_CATALOG_ENTRY_STATUSES = ["active", "disabled", "quarantined", "removed"] as const;
|
||||
export type ToolCatalogEntryStatus = (typeof TOOL_CATALOG_ENTRY_STATUSES)[number];
|
||||
|
||||
export const TOOL_RISK_LEVELS = ["low", "medium", "high", "critical", "read", "write", "destructive"] as const;
|
||||
export type ToolRiskLevel = (typeof TOOL_RISK_LEVELS)[number];
|
||||
|
||||
export const TOOL_PROFILE_STATUSES = ["draft", "active", "disabled", "archived"] as const;
|
||||
export type ToolProfileStatus = (typeof TOOL_PROFILE_STATUSES)[number];
|
||||
|
||||
export const TOOL_PROFILE_DEFAULT_ACTIONS = ["deny", "allow"] as const;
|
||||
export type ToolProfileDefaultAction = (typeof TOOL_PROFILE_DEFAULT_ACTIONS)[number];
|
||||
|
||||
export const TOOL_PROFILE_ENTRY_SELECTOR_TYPES = [
|
||||
"application",
|
||||
"connection",
|
||||
"catalog_entry",
|
||||
"tool_name",
|
||||
"risk_level",
|
||||
] as const;
|
||||
export type ToolProfileEntrySelectorType = (typeof TOOL_PROFILE_ENTRY_SELECTOR_TYPES)[number];
|
||||
|
||||
export const TOOL_PROFILE_ENTRY_EFFECTS = ["include", "exclude"] as const;
|
||||
export type ToolProfileEntryEffect = (typeof TOOL_PROFILE_ENTRY_EFFECTS)[number];
|
||||
|
||||
export const TOOL_PROFILE_BINDING_TARGET_TYPES = ["company", "agent", "project", "routine", "issue", "gateway"] as const;
|
||||
export type ToolProfileBindingTargetType = (typeof TOOL_PROFILE_BINDING_TARGET_TYPES)[number];
|
||||
|
||||
export const TOOL_MCP_GATEWAY_STATUSES = ["draft", "active", "disabled", "archived"] as const;
|
||||
export type ToolMcpGatewayStatus = (typeof TOOL_MCP_GATEWAY_STATUSES)[number];
|
||||
|
||||
export const TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES = [
|
||||
"gateway_only",
|
||||
"inherit_context_then_gateway",
|
||||
"gateway_then_context",
|
||||
] as const;
|
||||
export type ToolMcpGatewayDefaultProfileMode = (typeof TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES)[number];
|
||||
|
||||
export const TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES = [
|
||||
"none",
|
||||
"company",
|
||||
"project",
|
||||
"routine",
|
||||
"issue",
|
||||
"agent",
|
||||
] as const;
|
||||
export type ToolMcpGatewayContextScopeType = (typeof TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES)[number];
|
||||
|
||||
export const TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES = ["gateway_client", "heartbeat_run", "board_user", "agent"] as const;
|
||||
export type ToolMcpGatewayTokenSubjectType = (typeof TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES)[number];
|
||||
|
||||
export const TOOL_MCP_GATEWAY_TOKEN_ACTIONS = ["tools/list", "tools/call"] as const;
|
||||
export type ToolMcpGatewayTokenAction = (typeof TOOL_MCP_GATEWAY_TOKEN_ACTIONS)[number];
|
||||
|
||||
export const CONNECTION_TOKEN_ISSUANCE_PATHS = ["exchange", "oauth_access", "static"] as const;
|
||||
export type ConnectionTokenIssuancePath = (typeof CONNECTION_TOKEN_ISSUANCE_PATHS)[number];
|
||||
|
||||
export const CONNECTION_TOKEN_ISSUANCE_OUTCOMES = [
|
||||
"success",
|
||||
"denied",
|
||||
"rate_limited",
|
||||
"use_env_lease",
|
||||
"upstream_error",
|
||||
"failure",
|
||||
] as const;
|
||||
export type ConnectionTokenIssuanceOutcome = (typeof CONNECTION_TOKEN_ISSUANCE_OUTCOMES)[number];
|
||||
|
||||
export const TOOL_POLICY_TYPES = [
|
||||
"allow",
|
||||
"block",
|
||||
"require_approval",
|
||||
"trust_rule",
|
||||
"rate_limit",
|
||||
] as const;
|
||||
export type ToolPolicyType = (typeof TOOL_POLICY_TYPES)[number];
|
||||
|
||||
export const TOOL_POLICY_DECISIONS = ["allow", "deny", "require_approval", "rate_limited", "defer_runtime"] as const;
|
||||
export type ToolPolicyDecision = (typeof TOOL_POLICY_DECISIONS)[number];
|
||||
|
||||
export const TOOL_INVOCATION_STATUSES = [
|
||||
"pending",
|
||||
"authorized",
|
||||
"denied",
|
||||
"awaiting_approval",
|
||||
"executing",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"timed_out",
|
||||
"rate_limited",
|
||||
] as const;
|
||||
export type ToolInvocationStatus = (typeof TOOL_INVOCATION_STATUSES)[number];
|
||||
|
||||
export const TOOL_INVOCATION_APPROVAL_STATES = [
|
||||
"not_required",
|
||||
"required",
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"expired",
|
||||
] as const;
|
||||
export type ToolInvocationApprovalState = (typeof TOOL_INVOCATION_APPROVAL_STATES)[number];
|
||||
|
||||
export const TOOL_ACTION_REQUEST_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"executing",
|
||||
"rejected",
|
||||
"expired",
|
||||
"cancelled",
|
||||
"executed",
|
||||
"failed",
|
||||
] as const;
|
||||
export type ToolActionRequestStatus = (typeof TOOL_ACTION_REQUEST_STATUSES)[number];
|
||||
|
||||
export const TOOL_AUDIT_EVENT_TYPES = [
|
||||
"discovery",
|
||||
"policy_decision",
|
||||
"invocation_created",
|
||||
"call_started",
|
||||
"call_completed",
|
||||
"call_failed",
|
||||
"call_denied",
|
||||
"approval_requested",
|
||||
"approval_resolved",
|
||||
"session_revoked",
|
||||
"trust_rule_created",
|
||||
"trust_rule_revoked",
|
||||
"trust_rule_used",
|
||||
"runtime_started",
|
||||
"runtime_stopped",
|
||||
"rate_limited",
|
||||
] as const;
|
||||
export type ToolAuditEventType = (typeof TOOL_AUDIT_EVENT_TYPES)[number];
|
||||
|
||||
export const TOOL_AUDIT_OUTCOMES = ["pending", "success", "failure", "denied", "timeout", "cancelled"] as const;
|
||||
export type ToolAuditOutcome = (typeof TOOL_AUDIT_OUTCOMES)[number];
|
||||
|
||||
/**
|
||||
* Connection-level lifecycle events surfaced on the per-app Activity tab
|
||||
* alongside tool-call events (PAP-11284). These are derived from the
|
||||
* company activity log rows scoped to a single tool connection.
|
||||
*/
|
||||
export const TOOL_CONNECTION_LIFECYCLE_EVENT_TYPES = [
|
||||
"app_connected",
|
||||
"app_paused",
|
||||
"app_resumed",
|
||||
"allowlist_changed",
|
||||
"reconnected",
|
||||
"disconnected",
|
||||
"actions_quarantined",
|
||||
] as const;
|
||||
export type ToolConnectionLifecycleEventType = (typeof TOOL_CONNECTION_LIFECYCLE_EVENT_TYPES)[number];
|
||||
|
||||
export const TOOL_RUNTIME_KINDS = ["remote_session", "local_stdio"] as const;
|
||||
export type ToolRuntimeKind = (typeof TOOL_RUNTIME_KINDS)[number];
|
||||
|
||||
export const TOOL_RUNTIME_SLOT_STATUSES = ["starting", "running", "idle", "stopped", "failed", "disabled", "error"] as const;
|
||||
export type ToolRuntimeSlotStatus = (typeof TOOL_RUNTIME_SLOT_STATUSES)[number];
|
||||
|
||||
export const TOOL_RATE_LIMIT_WINDOW_KINDS = ["minute", "hour", "day", "month"] as const;
|
||||
export type ToolRateLimitWindowKind = (typeof TOOL_RATE_LIMIT_WINDOW_KINDS)[number];
|
||||
|
||||
export const TOOL_ACCESS_ACTIVITY_ACTIONS = [
|
||||
"tool_application.created",
|
||||
"tool_application.updated",
|
||||
"tool_application.archived",
|
||||
"tool_connection.created",
|
||||
"tool_connection.updated",
|
||||
"tool_connection.tested",
|
||||
"tool_connection.catalog_refreshed",
|
||||
"tool_profile.created",
|
||||
"tool_profile.updated",
|
||||
"tool_profile.duplicated",
|
||||
"tool_profile.deleted",
|
||||
"tool_profile.new_tools_reviewed",
|
||||
"tool_profile.bound",
|
||||
"tool_profile.unbound",
|
||||
"tool_policy.created",
|
||||
"tool_policy.updated",
|
||||
"tool_policy.disabled",
|
||||
"tool_trust_rule.created",
|
||||
"tool_trust_rule.revoked",
|
||||
"tool_runtime_slot.started",
|
||||
"tool_runtime_slot.stopped",
|
||||
"tool_action_request.created",
|
||||
"tool_action_request.resolved",
|
||||
] as const;
|
||||
export type ToolAccessActivityAction = (typeof TOOL_ACCESS_ACTIVITY_ACTIONS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
connectionDisplaySecondaryHint,
|
||||
humanizeConnectionDisplayName,
|
||||
} from "./humanize-connection.js";
|
||||
|
||||
describe("humanizeConnectionDisplayName", () => {
|
||||
it("hides raw IPs / hosts behind a generic label", () => {
|
||||
expect(humanizeConnectionDisplayName("127.0.0.1")).toBe("Custom app");
|
||||
expect(humanizeConnectionDisplayName("127.0.0.1:8931")).toBe("Custom app");
|
||||
expect(humanizeConnectionDisplayName("localhost")).toBe("Custom app");
|
||||
expect(humanizeConnectionDisplayName("example.com:8080")).toBe("Custom app");
|
||||
expect(humanizeConnectionDisplayName("https://mcp.example.com/sse")).toBe("Custom app");
|
||||
});
|
||||
|
||||
it("drops the `Plugin:` prefix and title-cases the package leaf", () => {
|
||||
expect(humanizeConnectionDisplayName("Plugin: paperclipai.plugin-briefs")).toBe("Briefs");
|
||||
expect(humanizeConnectionDisplayName("Plugin: acme.plugin-weekly-report")).toBe(
|
||||
"Weekly Report",
|
||||
);
|
||||
});
|
||||
|
||||
it("turns `vendor:tool` ids into Title Case With Spaces", () => {
|
||||
expect(humanizeConnectionDisplayName("mcp-remote-fixture:update_note")).toBe("Update Note");
|
||||
expect(humanizeConnectionDisplayName("github:create_issue")).toBe("Create Issue");
|
||||
});
|
||||
|
||||
it("title-cases a bare snake/kebab identifier", () => {
|
||||
expect(humanizeConnectionDisplayName("update_note")).toBe("Update Note");
|
||||
expect(humanizeConnectionDisplayName("send-email")).toBe("Send Email");
|
||||
});
|
||||
|
||||
it("passes through normal, already-human app names", () => {
|
||||
expect(humanizeConnectionDisplayName("Zapier")).toBe("Zapier");
|
||||
expect(humanizeConnectionDisplayName("Notion")).toBe("Notion");
|
||||
expect(humanizeConnectionDisplayName("Google Drive")).toBe("Google Drive");
|
||||
});
|
||||
|
||||
it("prefers an explicit title when provided", () => {
|
||||
expect(
|
||||
humanizeConnectionDisplayName("mcp-remote-fixture:update_note", { title: "Update note" }),
|
||||
).toBe("Update note");
|
||||
// Blank/whitespace titles fall back to derivation.
|
||||
expect(humanizeConnectionDisplayName("update_note", { title: " " })).toBe("Update Note");
|
||||
});
|
||||
|
||||
it("accepts a connection-like object and handles empty input", () => {
|
||||
expect(humanizeConnectionDisplayName({ name: "Plugin: acme.plugin-briefs" })).toBe("Briefs");
|
||||
expect(humanizeConnectionDisplayName("")).toBe("Custom app");
|
||||
expect(humanizeConnectionDisplayName(null)).toBe("Custom app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectionDisplaySecondaryHint", () => {
|
||||
it("surfaces `hosted at …` only for network addresses", () => {
|
||||
expect(connectionDisplaySecondaryHint("127.0.0.1")).toBe("hosted at 127.0.0.1");
|
||||
expect(connectionDisplaySecondaryHint("127.0.0.1:8931")).toBe("hosted at 127.0.0.1:8931");
|
||||
expect(connectionDisplaySecondaryHint({ name: "Zapier" })).toBeNull();
|
||||
expect(connectionDisplaySecondaryHint("Plugin: acme.plugin-briefs")).toBeNull();
|
||||
expect(connectionDisplaySecondaryHint("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Humanize engineering connection / tool identifiers for the prosumer Apps
|
||||
* surfaces (PAP-10897).
|
||||
*
|
||||
* `connection.name` (and tool ids) can carry raw IPs, `Plugin:` prefixes with
|
||||
* dotted package paths, and `vendor:tool` ids. None of that vocabulary may leak
|
||||
* into `/apps`, `/apps/attention`, or the App-detail header — only `/apps/advanced`
|
||||
* is allowed to show the raw identifiers. This module turns those identifiers
|
||||
* into recognizable, plain-language labels.
|
||||
*/
|
||||
|
||||
export interface HumanizableConnection {
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ConnectionLike = HumanizableConnection | string | null | undefined;
|
||||
|
||||
function rawNameOf(input: ConnectionLike): string {
|
||||
return (typeof input === "string" ? input : (input?.name ?? "")).trim();
|
||||
}
|
||||
|
||||
/** IP / URL / host:port / localhost — anything that reads as a network address. */
|
||||
function looksLikeNetworkAddress(raw: string): boolean {
|
||||
const v = raw.toLowerCase();
|
||||
if (v.includes("://")) return true; // any URL
|
||||
if (v === "localhost" || v.startsWith("localhost:")) return true;
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(v)) return true; // IPv4 (optional :port)
|
||||
if (/^[a-z0-9.-]+:\d+$/.test(v)) return true; // host:port
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Title-case a snake/kebab/dotted identifier: `update_note` → `Update Note`. */
|
||||
function titleCaseIdentifier(value: string): string {
|
||||
return value
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** `Plugin: paperclipai.plugin-briefs` → `Briefs`; null when not a plugin label. */
|
||||
function pluginPackageLabel(raw: string): string | null {
|
||||
const match = /^plugin:\s*(.+)$/i.exec(raw);
|
||||
if (!match) return null;
|
||||
let leaf = match[1].trim();
|
||||
// Keep the package leaf only: `paperclipai.plugin-briefs` → `plugin-briefs`.
|
||||
leaf = leaf.slice(leaf.lastIndexOf(".") + 1);
|
||||
// Drop the `plugin-` scaffolding leftover: `plugin-briefs` → `briefs`.
|
||||
leaf = leaf.replace(/^plugin[-_]/i, "");
|
||||
return titleCaseIdentifier(leaf) || "Custom app";
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an app/connection identifier (or a tool id) into a prosumer-friendly
|
||||
* label. Pass `options.title` (e.g. a catalog entry's `title`) to prefer a
|
||||
* known human title over any derivation.
|
||||
*
|
||||
* `127.0.0.1` → `Custom app`
|
||||
* `Plugin: paperclipai.plugin-briefs` → `Briefs`
|
||||
* `mcp-remote-fixture:update_note` → `Update Note`
|
||||
* `Zapier` / `Notion` → unchanged
|
||||
*/
|
||||
export function humanizeConnectionDisplayName(
|
||||
input: ConnectionLike,
|
||||
options: { title?: string | null } = {},
|
||||
): string {
|
||||
const title = options.title?.trim();
|
||||
if (title) return title; // a real, human title always wins
|
||||
|
||||
const raw = rawNameOf(input);
|
||||
if (!raw) return "Custom app";
|
||||
|
||||
if (looksLikeNetworkAddress(raw)) return "Custom app";
|
||||
|
||||
const pluginLabel = pluginPackageLabel(raw);
|
||||
if (pluginLabel) return pluginLabel;
|
||||
|
||||
// `vendor:tool` id (e.g. `mcp-remote-fixture:update_note`) → tool segment.
|
||||
if (raw.includes(":") && !raw.includes("://")) {
|
||||
const tool = raw.slice(raw.lastIndexOf(":") + 1).trim();
|
||||
if (tool) return titleCaseIdentifier(tool);
|
||||
}
|
||||
|
||||
// Already human (a space or any capital) → pass through untouched.
|
||||
if (/\s/.test(raw) || /[A-Z]/.test(raw)) return raw;
|
||||
|
||||
// Bare snake/kebab/dotted identifier → Title Case With Spaces.
|
||||
if (/[._-]/.test(raw)) return titleCaseIdentifier(raw);
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional secondary line for the App-detail page only: when the raw name is a
|
||||
* network address we hide it from the header but may still show `hosted at …`
|
||||
* underneath as a small trust/clarity hint. Returns null when there's nothing
|
||||
* worth surfacing.
|
||||
*/
|
||||
export function connectionDisplaySecondaryHint(input: ConnectionLike): string | null {
|
||||
const raw = rawNameOf(input);
|
||||
if (raw && looksLikeNetworkAddress(raw)) return `hosted at ${raw}`;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -133,6 +133,21 @@ export {
|
|||
type SourceTrustPromotionSource,
|
||||
type SourceTrustMetadata,
|
||||
} from "./trust-policy.js";
|
||||
export {
|
||||
TOOL_APP_GALLERY,
|
||||
getToolAppGalleryEntry,
|
||||
getToolAppGalleryEntryForUrl,
|
||||
type AppGalleryAuthKind,
|
||||
type AppGalleryCredentialField,
|
||||
type AppGalleryEntry,
|
||||
type AppGalleryKey,
|
||||
type AppGalleryTransportTemplate,
|
||||
} from "./tool-app-gallery.js";
|
||||
export {
|
||||
humanizeConnectionDisplayName,
|
||||
connectionDisplaySecondaryHint,
|
||||
type HumanizableConnection,
|
||||
} from "./humanize-connection.js";
|
||||
export {
|
||||
COMPANY_STATUSES,
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
|
|
@ -229,6 +244,8 @@ export {
|
|||
SECRET_PROVIDERS,
|
||||
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
|
||||
SECRET_PROJECTION_CLASSES,
|
||||
CLASS3_STATIC_LEASE_ALLOWLIST,
|
||||
SECRET_SCOPES,
|
||||
STORAGE_PROVIDERS,
|
||||
BILLING_TYPES,
|
||||
|
|
@ -258,6 +275,39 @@ export {
|
|||
JOIN_REQUEST_TYPES,
|
||||
JOIN_REQUEST_STATUSES,
|
||||
PERMISSION_KEYS,
|
||||
TOOL_ACTION_REQUEST_STATUSES,
|
||||
TOOL_APPLICATION_STATUSES,
|
||||
TOOL_APPLICATION_TYPES,
|
||||
TOOL_AUDIT_EVENT_TYPES,
|
||||
TOOL_AUDIT_OUTCOMES,
|
||||
TOOL_CATALOG_ENTRY_KINDS,
|
||||
TOOL_CATALOG_ENTRY_STATUSES,
|
||||
TOOL_CONNECTION_HEALTH_STATUSES,
|
||||
TOOL_CONNECTION_ATTENTION_HEALTH_STATUSES,
|
||||
isToolConnectionAttentionHealth,
|
||||
TOOL_CONNECTION_KINDS,
|
||||
TOOL_CONNECTION_LIFECYCLE_EVENT_TYPES,
|
||||
TOOL_INVOCATION_APPROVAL_STATES,
|
||||
TOOL_INVOCATION_STATUSES,
|
||||
TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES,
|
||||
TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES,
|
||||
TOOL_MCP_GATEWAY_STATUSES,
|
||||
TOOL_MCP_GATEWAY_TOKEN_ACTIONS,
|
||||
TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES,
|
||||
CONNECTION_TOKEN_ISSUANCE_PATHS,
|
||||
CONNECTION_TOKEN_ISSUANCE_OUTCOMES,
|
||||
TOOL_POLICY_DECISIONS,
|
||||
TOOL_POLICY_TYPES,
|
||||
TOOL_PROFILE_BINDING_TARGET_TYPES,
|
||||
TOOL_PROFILE_DEFAULT_ACTIONS,
|
||||
TOOL_PROFILE_ENTRY_EFFECTS,
|
||||
TOOL_PROFILE_ENTRY_SELECTOR_TYPES,
|
||||
TOOL_PROFILE_STATUSES,
|
||||
TOOL_RATE_LIMIT_WINDOW_KINDS,
|
||||
TOOL_RISK_LEVELS,
|
||||
TOOL_RUNTIME_KINDS,
|
||||
TOOL_RUNTIME_SLOT_STATUSES,
|
||||
TOOL_ACCESS_ACTIVITY_ACTIONS,
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
|
|
@ -370,6 +420,8 @@ export {
|
|||
type SecretProvider,
|
||||
type SecretProviderConfigStatus,
|
||||
type SecretProviderConfigHealthStatus,
|
||||
type SecretProjectionClass,
|
||||
type Class3StaticLeaseAllowlistKey,
|
||||
type SecretScope,
|
||||
type StorageProvider,
|
||||
type BillingType,
|
||||
|
|
@ -399,6 +451,9 @@ export {
|
|||
type JoinRequestType,
|
||||
type JoinRequestStatus,
|
||||
type PermissionKey,
|
||||
type ConnectionTokenIssuancePath,
|
||||
type ConnectionTokenIssuanceOutcome,
|
||||
type ToolAccessActivityAction,
|
||||
type PluginStatus,
|
||||
type PluginCategory,
|
||||
type PluginCapability,
|
||||
|
|
@ -784,6 +839,8 @@ export type {
|
|||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
|
|
@ -962,6 +1019,131 @@ export type {
|
|||
SecretStatus,
|
||||
SecretVersionSelector,
|
||||
SecretVersionStatus,
|
||||
ConnectToolAppResult,
|
||||
ToolOAuthStartResult,
|
||||
ToolActionRequest,
|
||||
ToolAccessDecision,
|
||||
ToolAccessDecisionInput,
|
||||
ToolAccessReasonCode,
|
||||
ToolAccessSelector,
|
||||
ToolPolicyConditions,
|
||||
ToolAppConnectionActionSummary,
|
||||
ToolTrustRuleArgumentFilters,
|
||||
ToolTrustRuleBatchApprovalConfig,
|
||||
ToolTrustRuleScopeInput,
|
||||
ToolMcpGateway,
|
||||
ToolMcpGatewayAuthConfig,
|
||||
ToolMcpGatewayBearerAuthConfig,
|
||||
ToolMcpGatewayClientSnippet,
|
||||
ToolMcpGatewayContextScopeType,
|
||||
ToolMcpGatewayDefaultProfileMode,
|
||||
ToolMcpGatewayCallerHeaderPolicy,
|
||||
ToolMcpGatewayGeneratedMetadataHeaderPolicy,
|
||||
ToolMcpGatewayHeaderPolicy,
|
||||
ToolMcpGatewayMetadataPolicy,
|
||||
ToolMcpGatewayOAuthReservedConfig,
|
||||
ToolMcpGatewayOnDemandToolsConfig,
|
||||
ToolMcpGatewayResponseHeaderPolicy,
|
||||
ToolMcpGatewayStaticHeaderPolicy,
|
||||
ToolMcpGatewayStatus,
|
||||
ToolMcpGatewayToken,
|
||||
ToolMcpGatewayTokenAction,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayTokenSubjectType,
|
||||
ToolMcpGatewayWithTokens,
|
||||
FinishToolAppResult,
|
||||
ToolActionRequestStatus,
|
||||
ToolApplicationType,
|
||||
ToolApplicationStatus,
|
||||
ToolAuditEventType,
|
||||
ToolAuditOutcome,
|
||||
ToolCallEvent,
|
||||
ToolCatalogEntryKind,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolConnectionTransport,
|
||||
ToolConnectionStatus,
|
||||
ToolConnectionKind,
|
||||
ToolCatalogEntryStatus,
|
||||
ToolAppAttentionItem,
|
||||
ToolAppAttentionReason,
|
||||
ToolAppsAttentionResponse,
|
||||
ToolActionRequestListItem,
|
||||
ToolActionRequestsResponse,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionTestDecision,
|
||||
ToolConnectionTestToolAccess,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
ToolConnectionTestCallStatusPhase,
|
||||
ToolConnectionLifecycleEvent,
|
||||
ToolConnectionLifecycleEventType,
|
||||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
ToolConnectionInstallTargetType,
|
||||
ConnectionTokenScope,
|
||||
ConnectionTokenRequest,
|
||||
ConnectionTokenAttribution,
|
||||
ConnectionTokenMintedResponse,
|
||||
ConnectionTokenUseEnvLeaseResponse,
|
||||
ConnectionTokenResponse,
|
||||
ConnectionTokenIssuance,
|
||||
ToolCredentialSecretRef,
|
||||
ToolInvocation,
|
||||
ToolInvocationApprovalState,
|
||||
ToolInvocationStatus,
|
||||
ToolPolicy,
|
||||
ToolPolicyDecision,
|
||||
ToolPolicyType,
|
||||
ToolProfile,
|
||||
ToolProfileBinding,
|
||||
ToolProfileBindingTargetType,
|
||||
ToolProfileDefaultAction,
|
||||
ToolProfileEntry,
|
||||
ToolProfileEntryEffect,
|
||||
ToolProfileEntrySelectorType,
|
||||
ToolProfileEffectiveSummary,
|
||||
ToolProfileNewToolReviewDecision,
|
||||
ToolProfileNewToolReviewItem,
|
||||
ToolProfileNewToolsReview,
|
||||
ToolProfileNewToolsReviewResult,
|
||||
ToolProfileSummary,
|
||||
ToolProfileStatus,
|
||||
ToolProfileWithDetails,
|
||||
ToolRateLimitCounter,
|
||||
ToolRateLimitRule,
|
||||
ToolRateLimitWindowKind,
|
||||
ToolRedactedValueSummary,
|
||||
ToolRiskLevel,
|
||||
ToolRunDecision,
|
||||
ToolRunDecisionLookup,
|
||||
ToolRuntimeKind,
|
||||
ToolRuntimeHealthSummary,
|
||||
ToolRuntimeMetricSnapshot,
|
||||
ToolRuntimeAlertRecommendation,
|
||||
ToolRuntimeAlertSeverity,
|
||||
ToolRuntimeAlertStatus,
|
||||
ToolRuntimeSupportMatrix,
|
||||
ToolRuntimeSlotStatus,
|
||||
ToolStdioCommandTemplate,
|
||||
ToolStdioTemplateToolSummary,
|
||||
McpConnectionCredentialRef,
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolCatalogEntry,
|
||||
ToolRuntimeSlot,
|
||||
ToolConnectionHealthCheckResult,
|
||||
ToolCatalogRefreshResult,
|
||||
ToolExampleInstallResult,
|
||||
ToolExampleSmokeCheck,
|
||||
ToolExampleSmokeResult,
|
||||
ToolExampleSummary,
|
||||
McpJsonImportDraft,
|
||||
McpJsonImportPreview,
|
||||
CreateToolTrustRuleFromActionRequest,
|
||||
RevokeToolTrustRule,
|
||||
Routine,
|
||||
RoutineEnvConfig,
|
||||
RoutineManagedByPlugin,
|
||||
|
|
@ -1108,6 +1290,23 @@ export {
|
|||
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
} from "./types/instance.js";
|
||||
|
||||
export type {
|
||||
SmokeLabServiceStatus,
|
||||
SmokeRun,
|
||||
SmokeRunStatus,
|
||||
SmokeRunStep,
|
||||
SmokeRunStepPath,
|
||||
SmokeRunStepStatus,
|
||||
SmokeRunTrigger,
|
||||
} from "./types/smoke-lab.js";
|
||||
|
||||
export {
|
||||
SMOKE_RUN_STATUSES,
|
||||
SMOKE_RUN_STEP_PATHS,
|
||||
SMOKE_RUN_STEP_STATUSES,
|
||||
SMOKE_RUN_TRIGGERS,
|
||||
} from "./types/smoke-lab.js";
|
||||
|
||||
export type {
|
||||
CloudUpstreamConnectStartResponse,
|
||||
CloudUpstreamActivationDecision,
|
||||
|
|
@ -1140,6 +1339,13 @@ export {
|
|||
patchInstanceExperimentalSettingsSchema,
|
||||
patchInstanceSettingsSchema,
|
||||
issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
createSmokeRunSchema,
|
||||
updateSmokeRunSchema,
|
||||
recordSmokeRunStepSchema,
|
||||
smokeRunStatusSchema,
|
||||
smokeRunStepPathSchema,
|
||||
smokeRunStepStatusSchema,
|
||||
smokeRunTriggerSchema,
|
||||
trustPresetSchema,
|
||||
lowTrustBoundarySchema,
|
||||
lowTrustReviewPresetPolicySchema,
|
||||
|
|
@ -1147,6 +1353,9 @@ export {
|
|||
type PatchInstanceExperimentalSettings,
|
||||
type PatchInstanceSettings,
|
||||
type IssueGraphLivenessAutoRecoveryRequest,
|
||||
type CreateSmokeRun,
|
||||
type UpdateSmokeRun,
|
||||
type RecordSmokeRunStep,
|
||||
type TrustPresetInput,
|
||||
type LowTrustBoundaryInput,
|
||||
type TrustAuthorizationPolicyInput,
|
||||
|
|
@ -1443,6 +1652,88 @@ export {
|
|||
rotateSecretSchema,
|
||||
secretBindingTargetSchema,
|
||||
updateSecretSchema,
|
||||
createToolActionRequestSchema,
|
||||
toolApplicationTypeSchema,
|
||||
toolApplicationStatusSchema,
|
||||
toolAuditEventTypeSchema,
|
||||
toolAuditOutcomeSchema,
|
||||
toolCatalogEntryKindSchema,
|
||||
toolCatalogEntryStatusSchema,
|
||||
toolConnectionHealthStatusSchema,
|
||||
toolConnectionKindSchema,
|
||||
toolConnectionTransportSchema,
|
||||
toolConnectionStatusSchema,
|
||||
toolCredentialPlacementSchema,
|
||||
toolCredentialSecretRefSchema,
|
||||
mcpConnectionCredentialRefSchema,
|
||||
toolInvocationApprovalStateSchema,
|
||||
toolInvocationStatusSchema,
|
||||
toolMcpGatewayAuthConfigSchema,
|
||||
toolMcpGatewayContextScopeTypeSchema,
|
||||
toolMcpGatewayDefaultProfileModeSchema,
|
||||
toolMcpGatewayHeaderPolicySchema,
|
||||
toolMcpGatewayMetadataPolicySchema,
|
||||
toolMcpGatewayOnDemandToolsConfigSchema,
|
||||
toolMcpGatewayStatusSchema,
|
||||
toolMcpGatewayTokenActionSchema,
|
||||
toolMcpGatewayTokenSubjectTypeSchema,
|
||||
toolPolicyDecisionSchema,
|
||||
toolPolicyTypeSchema,
|
||||
toolProfileBindingTargetTypeSchema,
|
||||
toolProfileDefaultActionSchema,
|
||||
toolProfileEntryEffectSchema,
|
||||
toolProfileEntrySelectorTypeSchema,
|
||||
toolProfileStatusSchema,
|
||||
toolRateLimitWindowKindSchema,
|
||||
toolRedactedValueSummarySchema,
|
||||
toolRiskLevelSchema,
|
||||
toolRuntimeKindSchema,
|
||||
toolRuntimeSlotStatusSchema,
|
||||
toolTransportConfigSchema,
|
||||
toolAccessSelectorSchema,
|
||||
toolPolicyConditionsSchema,
|
||||
toolRateLimitRuleSchema,
|
||||
toolTrustRuleArgumentFiltersSchema,
|
||||
toolTrustRuleBatchApprovalSchema,
|
||||
toolTrustRuleScopeSchema,
|
||||
connectionTokenRequestSchema,
|
||||
toolConnectionTestCallSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
importMcpJsonSchema,
|
||||
createToolTrustRuleFromActionRequestSchema,
|
||||
revokeToolTrustRuleSchema,
|
||||
connectToolAppSchema,
|
||||
reconnectToolAppSchema,
|
||||
createToolApplicationSchema,
|
||||
finishToolAppSchema,
|
||||
updateToolApplicationSchema,
|
||||
createToolConnectionSchema,
|
||||
createToolMcpGatewaySchema,
|
||||
createToolMcpGatewayTokenSchema,
|
||||
createToolStdioCommandTemplateSchema,
|
||||
disableToolStdioCommandTemplateSchema,
|
||||
updateToolConnectionSchema,
|
||||
putToolConnectionInstallsSchema,
|
||||
updateToolMcpGatewaySchema,
|
||||
createToolInvocationSchema,
|
||||
createToolPolicySchema,
|
||||
createToolProfileBindingForProfileSchema,
|
||||
createToolProfileBindingSchema,
|
||||
createToolProfileEntryForProfileSchema,
|
||||
createToolProfileEntrySchema,
|
||||
createToolProfileSchema,
|
||||
createToolProfileWithEntriesSchema,
|
||||
deleteToolProfileSchema,
|
||||
duplicateToolPolicySchema,
|
||||
duplicateToolProfileSchema,
|
||||
reorderToolPoliciesSchema,
|
||||
reviewToolProfileNewToolsSchema,
|
||||
unbindToolProfileBindingSchema,
|
||||
updateToolPolicySchema,
|
||||
updateToolProfileEntrySchema,
|
||||
updateToolProfileSchema,
|
||||
updateToolProfileWithEntriesSchema,
|
||||
upsertToolCatalogEntrySchema,
|
||||
createRoutineSchema,
|
||||
updateRoutineSchema,
|
||||
createRoutineTriggerSchema,
|
||||
|
|
@ -1472,6 +1763,42 @@ export {
|
|||
type RemoteSecretImportSelection,
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
type ConnectToolApp,
|
||||
type ReconnectToolApp,
|
||||
type CreateToolActionRequest,
|
||||
type CreateToolApplication,
|
||||
type FinishToolApp,
|
||||
type UpdateToolApplication,
|
||||
type CreateToolConnection,
|
||||
type CreateToolMcpGateway,
|
||||
type CreateToolMcpGatewayToken,
|
||||
type CreateToolStdioCommandTemplate,
|
||||
type DisableToolStdioCommandTemplate,
|
||||
type UpdateToolConnection,
|
||||
type PutToolConnectionInstalls,
|
||||
type UpdateToolMcpGateway,
|
||||
type ConnectionTokenRequestInput,
|
||||
type ImportMcpJson,
|
||||
type ToolPolicyTestRequestInput,
|
||||
type CreateToolInvocation,
|
||||
type CreateToolPolicy,
|
||||
type DuplicateToolPolicy,
|
||||
type CreateToolProfile,
|
||||
type CreateToolProfileBinding,
|
||||
type CreateToolProfileBindingForProfile,
|
||||
type CreateToolProfileEntry,
|
||||
type CreateToolProfileEntryForProfile,
|
||||
type CreateToolProfileWithEntries,
|
||||
type DeleteToolProfile,
|
||||
type DuplicateToolProfile,
|
||||
type ReviewToolProfileNewTools,
|
||||
type ReorderToolPolicies,
|
||||
type UpdateToolPolicy,
|
||||
type UpdateToolProfileEntry,
|
||||
type UpdateToolProfile,
|
||||
type UpdateToolProfileWithEntries,
|
||||
type UnbindToolProfileBinding,
|
||||
type UpsertToolCatalogEntry,
|
||||
type CreateRoutine,
|
||||
type UpdateRoutine,
|
||||
type CreateRoutineTrigger,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
acceptIssueThreadInteractionSchema,
|
||||
askUserQuestionsResultSchema,
|
||||
createIssueThreadInteractionSchema,
|
||||
requestConfirmationPayloadSchema,
|
||||
requestConfirmationResultSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
} from "./validators/issue.js";
|
||||
|
||||
|
|
@ -39,6 +41,49 @@ describe("issue thread interaction schemas", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("round-trips versioned tool action payload and lifecycle metadata", () => {
|
||||
const payload = requestConfirmationPayloadSchema.parse({
|
||||
version: 1,
|
||||
prompt: "Approve send_email?",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
actionRequestId: "11111111-1111-4111-8111-111111111111",
|
||||
invocationId: "22222222-2222-4222-8222-222222222222",
|
||||
toolName: "send_email",
|
||||
toolDisplayName: "Send email",
|
||||
connectionId: "33333333-3333-4333-8333-333333333333",
|
||||
applicationId: "44444444-4444-4444-8444-444444444444",
|
||||
appDisplayName: "Gmail",
|
||||
risk: "write",
|
||||
previewMarkdown: "Send an email to the reviewed recipient.",
|
||||
argumentsSummaryJson: '{"to":"recipient@example.com"}',
|
||||
argumentsHash: "reviewed-arguments-hash",
|
||||
expiresAt: "2026-07-12T16:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const result = requestConfirmationResultSchema.parse({
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
status: "executed",
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
updatedAt: "2026-07-12T15:05:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.toolAction).toMatchObject({
|
||||
version: 1,
|
||||
toolDisplayName: "Send email",
|
||||
risk: "write",
|
||||
argumentsHash: "reviewed-arguments-hash",
|
||||
});
|
||||
expect(result.toolAction).toMatchObject({ version: 1, status: "executed" });
|
||||
expect(requestConfirmationPayloadSchema.parse({ version: 1, prompt: "Legacy confirmation?" }).toolAction)
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts issue document targets for request_confirmation interactions", () => {
|
||||
const parsed = createIssueThreadInteractionSchema.parse({
|
||||
kind: "request_confirmation",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getToolAppGalleryEntryForUrl,
|
||||
TOOL_APP_GALLERY,
|
||||
} from "./tool-app-gallery.js";
|
||||
|
||||
describe("tool app gallery URL matching", () => {
|
||||
it("matches pasted links against gallery URL patterns", () => {
|
||||
expect(getToolAppGalleryEntryForUrl("https://mcp.zapier.com/api/mcp")?.key).toBe("zapier");
|
||||
expect(getToolAppGalleryEntryForUrl("https://api.githubcopilot.com/mcp/")?.key).toBe("github");
|
||||
expect(getToolAppGalleryEntryForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.key).toBe("google-sheets");
|
||||
});
|
||||
|
||||
it("returns null for invalid or unknown links", () => {
|
||||
expect(getToolAppGalleryEntryForUrl("not a url")).toBeNull();
|
||||
expect(getToolAppGalleryEntryForUrl("https://example.com/mcp")).toBeNull();
|
||||
expect(getToolAppGalleryEntryForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not list Google Drive until its OAuth client flow is supported", () => {
|
||||
expect(TOOL_APP_GALLERY.map((entry) => entry.key)).not.toContain("google-drive");
|
||||
expect(getToolAppGalleryEntryForUrl("https://mcp.google.com/drive")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps every gallery entry reachable through at least one pattern", () => {
|
||||
for (const entry of TOOL_APP_GALLERY) {
|
||||
const example = entry.urlPatterns[0]?.replace("*", "example");
|
||||
expect(example, `${entry.key} has a pattern`).toBeTruthy();
|
||||
expect(getToolAppGalleryEntryForUrl(example!)?.key).toBe(entry.key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
import type { ToolConnectionTransport } from "./types/tool-access.js";
|
||||
|
||||
export type AppGalleryAuthKind = "oauth" | "api_key" | "none";
|
||||
|
||||
export interface AppGalleryCredentialField {
|
||||
label: string;
|
||||
configPath: string;
|
||||
helpUrl: string;
|
||||
required?: boolean;
|
||||
placement?: "header" | "env";
|
||||
key?: string;
|
||||
prefix?: string | null;
|
||||
}
|
||||
|
||||
export type AppGalleryTransportTemplate =
|
||||
| {
|
||||
transport: Extract<ToolConnectionTransport, "remote_http">;
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
transport: Extract<ToolConnectionTransport, "local_stdio">;
|
||||
templateKey: string;
|
||||
};
|
||||
|
||||
export interface AppGalleryEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
tagline: string;
|
||||
description?: string;
|
||||
authKind: AppGalleryAuthKind;
|
||||
transportTemplate: AppGalleryTransportTemplate;
|
||||
credentialFields: AppGalleryCredentialField[];
|
||||
recommendedDefaults: Record<string, unknown>;
|
||||
urlPatterns: string[];
|
||||
availability?: {
|
||||
available: boolean;
|
||||
reason?: string | null;
|
||||
robotEmail?: string | null;
|
||||
};
|
||||
oauth?: {
|
||||
provider: string;
|
||||
scopes: string[];
|
||||
tokenUrl?: string | null;
|
||||
metadataUrl?: string | null;
|
||||
authorizationUrl?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const favicon = (domain: string) => `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||
|
||||
export const TOOL_APP_GALLERY = [
|
||||
{
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
logoUrl: favicon("zapier.com"),
|
||||
tagline: "Connect Zapier-hosted actions to your Paperclip agents.",
|
||||
description: "Let agents use Zapier automations across the apps your business already runs. Good for handoffs, lightweight operations, and cross-app updates that should stay visible in Paperclip.",
|
||||
authKind: "api_key",
|
||||
transportTemplate: {
|
||||
transport: "remote_http",
|
||||
url: "https://mcp.zapier.com/api/mcp",
|
||||
},
|
||||
credentialFields: [
|
||||
{
|
||||
label: "Zapier MCP token",
|
||||
configPath: "credentials.authorization",
|
||||
helpUrl: "https://zapier.com/app/settings/authorizations",
|
||||
required: true,
|
||||
placement: "header",
|
||||
key: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
},
|
||||
],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.zapier.com/*"],
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
logoUrl: favicon("github.com"),
|
||||
tagline: "Read and manage GitHub issues and pull requests.",
|
||||
description: "Give agents a governed way to inspect repositories, issues, and pull requests. Useful when engineering work needs GitHub context or small updates without leaving Paperclip.",
|
||||
authKind: "api_key",
|
||||
transportTemplate: {
|
||||
transport: "remote_http",
|
||||
url: "https://api.githubcopilot.com/mcp/",
|
||||
},
|
||||
credentialFields: [
|
||||
{
|
||||
label: "GitHub token",
|
||||
configPath: "credentials.authorization",
|
||||
helpUrl: "https://github.com/settings/tokens",
|
||||
required: true,
|
||||
placement: "header",
|
||||
key: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
},
|
||||
],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://api.githubcopilot.com/mcp/*"],
|
||||
},
|
||||
{
|
||||
key: "slack",
|
||||
name: "Slack",
|
||||
logoUrl: favicon("slack.com"),
|
||||
tagline: "Search channels and coordinate Slack actions.",
|
||||
description: "Let agents search workspace conversations and coordinate in Slack when work needs team context. Message-sending actions can still ask a human first.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: {
|
||||
transport: "remote_http",
|
||||
url: "https://mcp.slack.com/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.slack.com/*"],
|
||||
oauth: {
|
||||
provider: "slack",
|
||||
scopes: ["channels:read", "chat:write", "search:read"],
|
||||
authorizationUrl: "https://slack.com/oauth/v2/authorize",
|
||||
tokenUrl: "https://slack.com/api/oauth.v2.access",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "notion",
|
||||
name: "Notion",
|
||||
logoUrl: favicon("notion.so"),
|
||||
tagline: "Search and update Notion workspace content.",
|
||||
description: "Connect Notion so agents can find docs, read project notes, and update workspace pages. Use it for company memory that lives outside Paperclip.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: {
|
||||
transport: "remote_http",
|
||||
url: "https://mcp.notion.com/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.notion.com/*"],
|
||||
oauth: {
|
||||
provider: "notion",
|
||||
scopes: ["read_content", "update_content"],
|
||||
authorizationUrl: "https://api.notion.com/v1/oauth/authorize",
|
||||
tokenUrl: "https://api.notion.com/v1/oauth/token",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "linear",
|
||||
name: "Linear",
|
||||
logoUrl: favicon("linear.app"),
|
||||
tagline: "Read and update Linear issues from agent workflows.",
|
||||
description: "Let agents look up Linear work and make issue updates when their Paperclip tasks depend on your existing product queue.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: {
|
||||
transport: "remote_http",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.linear.app/*"],
|
||||
oauth: {
|
||||
provider: "linear",
|
||||
scopes: ["read", "write"],
|
||||
authorizationUrl: "https://linear.app/oauth/authorize",
|
||||
tokenUrl: "https://api.linear.app/oauth/token",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
logoUrl: favicon("sheets.google.com"),
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
description: "Let agents read and update only the spreadsheets you choose. Share each sheet with the robot email, then paste the sheet links here.",
|
||||
authKind: "none",
|
||||
transportTemplate: {
|
||||
transport: "local_stdio",
|
||||
templateKey: "paperclip.google-sheets",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*", "https://sheets.google.com/*"],
|
||||
},
|
||||
{
|
||||
key: "context7",
|
||||
name: "Context7",
|
||||
logoUrl: favicon("context7.com"),
|
||||
tagline: "Fetch up-to-date library documentation with Context7.",
|
||||
description: "Let agents pull current library documentation while they work. It is a low-risk reference app for coding and research tasks.",
|
||||
authKind: "none",
|
||||
transportTemplate: {
|
||||
transport: "remote_http",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: [],
|
||||
},
|
||||
urlPatterns: ["https://mcp.context7.com/*"],
|
||||
},
|
||||
] satisfies AppGalleryEntry[];
|
||||
|
||||
export type AppGalleryKey = (typeof TOOL_APP_GALLERY)[number]["key"];
|
||||
|
||||
export function getToolAppGalleryEntry(key: string): AppGalleryEntry | null {
|
||||
return TOOL_APP_GALLERY.find((entry) => entry.key === key) ?? null;
|
||||
}
|
||||
|
||||
function wildcardPatternToRegExp(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
export function getToolAppGalleryEntryForUrl(
|
||||
link: string,
|
||||
entries: readonly AppGalleryEntry[] = TOOL_APP_GALLERY,
|
||||
): AppGalleryEntry | null {
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = new URL(link.trim()).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return entries.find((entry) =>
|
||||
entry.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
|
||||
) ?? null;
|
||||
}
|
||||
|
|
@ -48,6 +48,21 @@ export type {
|
|||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
} from "./instance.js";
|
||||
export type {
|
||||
SmokeLabServiceStatus,
|
||||
SmokeRun,
|
||||
SmokeRunStatus,
|
||||
SmokeRunStep,
|
||||
SmokeRunStepPath,
|
||||
SmokeRunStepStatus,
|
||||
SmokeRunTrigger,
|
||||
} from "./smoke-lab.js";
|
||||
export {
|
||||
SMOKE_RUN_STATUSES,
|
||||
SMOKE_RUN_STEP_PATHS,
|
||||
SMOKE_RUN_STEP_STATUSES,
|
||||
SMOKE_RUN_TRIGGERS,
|
||||
} from "./smoke-lab.js";
|
||||
export {
|
||||
DAILY_RETENTION_PRESETS,
|
||||
WEEKLY_RETENTION_PRESETS,
|
||||
|
|
@ -304,6 +319,133 @@ export type {
|
|||
WorkspaceFileWorkspaceKind,
|
||||
ResolvedWorkspaceResource,
|
||||
} from "./workspace-file-resource.js";
|
||||
export type {
|
||||
ToolActionRequest,
|
||||
ToolAccessDecision,
|
||||
ToolAccessDecisionInput,
|
||||
ToolAccessReasonCode,
|
||||
ToolAccessSelector,
|
||||
ConnectToolAppResult,
|
||||
FinishToolAppResult,
|
||||
ToolOAuthStartResult,
|
||||
ToolTrustRuleArgumentFilters,
|
||||
ToolTrustRuleBatchApprovalConfig,
|
||||
ToolTrustRuleScopeInput,
|
||||
ToolActionRequestStatus,
|
||||
ToolApplication,
|
||||
ToolApplicationStatus,
|
||||
ToolApplicationType,
|
||||
ToolAuditEventType,
|
||||
ToolAuditOutcome,
|
||||
ToolCallEvent,
|
||||
ToolCatalogEntry,
|
||||
ToolCatalogEntryKind,
|
||||
ToolCatalogEntryStatus,
|
||||
ToolAppAttentionItem,
|
||||
ToolAppAttentionReason,
|
||||
ToolAppsAttentionResponse,
|
||||
ToolActionRequestListItem,
|
||||
ToolActionRequestsResponse,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionLifecycleEvent,
|
||||
ToolConnectionLifecycleEventType,
|
||||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
ToolConnectionInstallTargetType,
|
||||
ConnectionTokenAttribution,
|
||||
ConnectionTokenIssuance,
|
||||
ConnectionTokenMintedResponse,
|
||||
ConnectionTokenRequest,
|
||||
ConnectionTokenResponse,
|
||||
ConnectionTokenScope,
|
||||
ConnectionTokenUseEnvLeaseResponse,
|
||||
ToolConnection,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolConnectionTransport,
|
||||
ToolConnectionStatus,
|
||||
ToolConnectionKind,
|
||||
ToolCredentialSecretRef,
|
||||
ToolInvocation,
|
||||
ToolInvocationApprovalState,
|
||||
ToolInvocationStatus,
|
||||
ToolPolicy,
|
||||
ToolPolicyConditions,
|
||||
ToolPolicyDecision,
|
||||
ToolPolicyType,
|
||||
ToolMcpGateway,
|
||||
ToolMcpGatewayAuthConfig,
|
||||
ToolMcpGatewayBearerAuthConfig,
|
||||
ToolMcpGatewayClientSnippet,
|
||||
ToolMcpGatewayContextScopeType,
|
||||
ToolMcpGatewayDefaultProfileMode,
|
||||
ToolMcpGatewayCallerHeaderPolicy,
|
||||
ToolMcpGatewayGeneratedMetadataHeaderPolicy,
|
||||
ToolMcpGatewayHeaderPolicy,
|
||||
ToolMcpGatewayMetadataPolicy,
|
||||
ToolMcpGatewayOAuthReservedConfig,
|
||||
ToolMcpGatewayOnDemandToolsConfig,
|
||||
ToolMcpGatewayResponseHeaderPolicy,
|
||||
ToolMcpGatewayStaticHeaderPolicy,
|
||||
ToolMcpGatewayStatus,
|
||||
ToolMcpGatewayToken,
|
||||
ToolMcpGatewayTokenAction,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayTokenSubjectType,
|
||||
ToolMcpGatewayWithTokens,
|
||||
ToolProfile,
|
||||
ToolProfileBinding,
|
||||
ToolProfileBindingTargetType,
|
||||
ToolProfileDefaultAction,
|
||||
ToolProfileEntry,
|
||||
ToolProfileEntryEffect,
|
||||
ToolProfileEntrySelectorType,
|
||||
ToolProfileEffectiveSummary,
|
||||
ToolProfileNewToolReviewDecision,
|
||||
ToolProfileNewToolReviewItem,
|
||||
ToolProfileNewToolsReview,
|
||||
ToolProfileNewToolsReviewResult,
|
||||
ToolProfileSummary,
|
||||
ToolProfileStatus,
|
||||
ToolProfileWithDetails,
|
||||
ToolRateLimitCounter,
|
||||
ToolRateLimitRule,
|
||||
ToolRateLimitWindowKind,
|
||||
ToolRedactedValueSummary,
|
||||
ToolRiskLevel,
|
||||
ToolRunDecision,
|
||||
ToolRunDecisionLookup,
|
||||
ToolRuntimeKind,
|
||||
ToolRuntimeHealthSummary,
|
||||
ToolRuntimeMetricSnapshot,
|
||||
ToolRuntimeAlertRecommendation,
|
||||
ToolRuntimeAlertSeverity,
|
||||
ToolRuntimeAlertStatus,
|
||||
ToolRuntimeSupportMatrix,
|
||||
ToolRuntimeSlot,
|
||||
ToolRuntimeSlotStatus,
|
||||
ToolStdioCommandTemplate,
|
||||
ToolStdioTemplateToolSummary,
|
||||
ToolAppConnectionActionSummary,
|
||||
McpConnectionCredentialRef,
|
||||
ToolConnectionHealthCheckResult,
|
||||
ToolCatalogRefreshResult,
|
||||
ToolExampleInstallResult,
|
||||
ToolExampleSmokeCheck,
|
||||
ToolExampleSmokeResult,
|
||||
ToolExampleSummary,
|
||||
McpJsonImportDraft,
|
||||
McpJsonImportPreview,
|
||||
CreateToolTrustRuleFromActionRequest,
|
||||
RevokeToolTrustRule,
|
||||
ToolConnectionTestDecision,
|
||||
ToolConnectionTestToolAccess,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
ToolConnectionTestCallStatusPhase,
|
||||
} from "./tool-access.js";
|
||||
export type {
|
||||
IssueWorkProduct,
|
||||
IssueWorkProductType,
|
||||
|
|
@ -411,6 +553,8 @@ export type {
|
|||
RequestConfirmationTarget,
|
||||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
RequestCheckboxConfirmationPayload,
|
||||
RequestCheckboxConfirmationResult,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableEnvironments: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
enableApps: boolean;
|
||||
enablePipelines: boolean;
|
||||
enableCases: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
|
|
@ -56,6 +57,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableExperimentalFileViewer: boolean;
|
||||
enableCloudSync: boolean;
|
||||
enableExternalObjects: boolean;
|
||||
enableSmokeLab: boolean;
|
||||
enableBuiltInAgents: boolean;
|
||||
enableDecisions: boolean;
|
||||
enableGoalsSidebarLink: boolean;
|
||||
|
|
|
|||
|
|
@ -1044,6 +1044,43 @@ export type RequestConfirmationTarget =
|
|||
| RequestConfirmationIssueDocumentTarget
|
||||
| RequestConfirmationCustomTarget;
|
||||
|
||||
/**
|
||||
* Enrichment block carried on a `request_confirmation` interaction when it gates
|
||||
* a write/destructive MCP tool call (PAP-13726 §D1). Its presence flips the feed
|
||||
* card into the dedicated tool-approval rendering (PAP-13745). Arguments are
|
||||
* redacted server-side before this reaches the client.
|
||||
*/
|
||||
export interface RequestConfirmationToolActionPayload {
|
||||
version: 1;
|
||||
actionRequestId: string;
|
||||
invocationId: string;
|
||||
toolName: string;
|
||||
toolDisplayName: string;
|
||||
connectionId: string | null;
|
||||
applicationId: string | null;
|
||||
appDisplayName: string | null;
|
||||
risk: "write" | "destructive";
|
||||
previewMarkdown: string;
|
||||
argumentsSummaryJson: string;
|
||||
argumentsHash: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status written back onto the resolved interaction once the operator
|
||||
* approves. `approve = run`, so the terminal states are executed/failed/expired —
|
||||
* never a bare "accepted".
|
||||
*/
|
||||
export interface RequestConfirmationToolActionResult {
|
||||
version: 1;
|
||||
status: "approved" | "executing" | "executed" | "failed" | "expired";
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
resultSummary?: string | null;
|
||||
resultHref?: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RequestConfirmationPayload {
|
||||
version: 1;
|
||||
prompt: string;
|
||||
|
|
@ -1056,6 +1093,7 @@ export interface RequestConfirmationPayload {
|
|||
detailsMarkdown?: string | null;
|
||||
supersedeOnUserComment?: boolean;
|
||||
target?: RequestConfirmationTarget | null;
|
||||
toolAction?: RequestConfirmationToolActionPayload;
|
||||
}
|
||||
|
||||
export interface RequestCheckboxConfirmationOption {
|
||||
|
|
@ -1122,6 +1160,7 @@ export interface RequestConfirmationResult {
|
|||
recoveryActionId?: string | null;
|
||||
updatedAt?: string | null;
|
||||
} | null;
|
||||
toolAction?: RequestConfirmationToolActionResult;
|
||||
}
|
||||
|
||||
export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult {
|
||||
|
|
|
|||
|
|
@ -740,15 +740,17 @@ export interface PluginStateRecord {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a plugin's instance configuration as persisted in the
|
||||
* Domain type for a plugin's company-scoped configuration as persisted in the
|
||||
* `plugin_config` table.
|
||||
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||
*/
|
||||
export interface PluginConfig {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. Unique — each plugin has at most one config row. */
|
||||
/** FK to `plugins.id`. Unique together with `companyId`. */
|
||||
pluginId: string;
|
||||
/** FK to `companies.id`. */
|
||||
companyId: string;
|
||||
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
|
||||
configJson: Record<string, unknown>;
|
||||
/** Most recent config validation error, if any. */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
SecretProvider,
|
||||
SecretProviderConfigHealthStatus,
|
||||
SecretProviderConfigStatus,
|
||||
SecretProjectionClass,
|
||||
SecretScope,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
|
|
@ -17,6 +18,7 @@ export type {
|
|||
SecretProvider,
|
||||
SecretProviderConfigHealthStatus,
|
||||
SecretProviderConfigStatus,
|
||||
SecretProjectionClass,
|
||||
SecretScope,
|
||||
SecretStatus,
|
||||
SecretVersionStatus,
|
||||
|
|
@ -33,6 +35,8 @@ export interface EnvSecretRefBinding {
|
|||
type: "secret_ref";
|
||||
secretId: string;
|
||||
version?: SecretVersionSelector;
|
||||
projectionClass?: SecretProjectionClass;
|
||||
projectionAllowlistKey?: string | null;
|
||||
}
|
||||
|
||||
export interface EnvUserSecretRefBinding {
|
||||
|
|
@ -254,6 +258,8 @@ export interface CompanySecretBinding {
|
|||
versionSelector: SecretVersionSelector;
|
||||
required: boolean;
|
||||
label: string | null;
|
||||
projectionClass: SecretProjectionClass;
|
||||
projectionAllowlistKey: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -284,7 +290,7 @@ export interface SecretAccessEvent {
|
|||
credentialSubjectId: string | null;
|
||||
actorType: "agent" | "user" | "system" | "plugin";
|
||||
actorId: string | null;
|
||||
consumerType: SecretBindingTargetType;
|
||||
consumerType: SecretBindingTargetType | "plugin_worker";
|
||||
consumerId: string;
|
||||
configPath: string | null;
|
||||
issueId: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
export const SMOKE_RUN_TRIGGERS = ["manual", "routine", "ci"] as const;
|
||||
export type SmokeRunTrigger = (typeof SMOKE_RUN_TRIGGERS)[number];
|
||||
|
||||
export const SMOKE_RUN_STATUSES = ["running", "passed", "failed", "cancelled"] as const;
|
||||
export type SmokeRunStatus = (typeof SMOKE_RUN_STATUSES)[number];
|
||||
|
||||
export const SMOKE_RUN_STEP_PATHS = ["P1", "P2", "P3", "P4", "P5", "P6", "P7"] as const;
|
||||
export type SmokeRunStepPath = (typeof SMOKE_RUN_STEP_PATHS)[number];
|
||||
|
||||
export const SMOKE_RUN_STEP_STATUSES = ["pass", "fail", "skipped"] as const;
|
||||
export type SmokeRunStepStatus = (typeof SMOKE_RUN_STEP_STATUSES)[number];
|
||||
|
||||
export interface SmokeRun {
|
||||
id: string;
|
||||
companyId: string;
|
||||
trigger: SmokeRunTrigger;
|
||||
status: SmokeRunStatus;
|
||||
startedAt: Date | string;
|
||||
finishedAt: Date | string | null;
|
||||
summary: Record<string, unknown>;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface SmokeRunStep {
|
||||
id: string;
|
||||
companyId: string;
|
||||
runId: string;
|
||||
path: SmokeRunStepPath;
|
||||
scenarioStep: string;
|
||||
status: SmokeRunStepStatus;
|
||||
detail: string | null;
|
||||
screenshotArtifactRef: Record<string, unknown> | null;
|
||||
durationMs: number | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface SmokeLabServiceStatus {
|
||||
id: "fake-oauth" | "http-mcp-fixture";
|
||||
label: string;
|
||||
status: "stopped" | "running" | "error";
|
||||
url: string | null;
|
||||
health: Record<string, unknown> | null;
|
||||
detail: string | null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,6 +21,19 @@ export {
|
|||
type ResolveBudgetIncident,
|
||||
} from "./budget.js";
|
||||
|
||||
export {
|
||||
createSmokeRunSchema,
|
||||
updateSmokeRunSchema,
|
||||
recordSmokeRunStepSchema,
|
||||
smokeRunStatusSchema,
|
||||
smokeRunStepPathSchema,
|
||||
smokeRunStepStatusSchema,
|
||||
smokeRunTriggerSchema,
|
||||
type CreateSmokeRun,
|
||||
type UpdateSmokeRun,
|
||||
type RecordSmokeRunStep,
|
||||
} from "./smoke-lab.js";
|
||||
|
||||
export {
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
|
|
@ -695,3 +708,128 @@ export {
|
|||
type SetPluginState,
|
||||
type ListPluginState,
|
||||
} from "./plugin.js";
|
||||
|
||||
export {
|
||||
createToolActionRequestSchema,
|
||||
toolApplicationTypeSchema,
|
||||
toolApplicationStatusSchema,
|
||||
toolAuditEventTypeSchema,
|
||||
toolAuditOutcomeSchema,
|
||||
toolCatalogEntryKindSchema,
|
||||
toolCatalogEntryStatusSchema,
|
||||
toolConnectionHealthStatusSchema,
|
||||
toolConnectionKindSchema,
|
||||
toolConnectionStatusSchema,
|
||||
toolConnectionTransportSchema,
|
||||
toolCredentialSecretRefSchema,
|
||||
toolCredentialPlacementSchema,
|
||||
toolInvocationApprovalStateSchema,
|
||||
toolInvocationStatusSchema,
|
||||
toolMcpGatewayAuthConfigSchema,
|
||||
toolMcpGatewayContextScopeTypeSchema,
|
||||
toolMcpGatewayDefaultProfileModeSchema,
|
||||
toolMcpGatewayHeaderPolicySchema,
|
||||
toolMcpGatewayMetadataPolicySchema,
|
||||
toolMcpGatewayOnDemandToolsConfigSchema,
|
||||
toolMcpGatewayStatusSchema,
|
||||
toolMcpGatewayTokenActionSchema,
|
||||
toolMcpGatewayTokenSubjectTypeSchema,
|
||||
toolPolicyDecisionSchema,
|
||||
toolPolicyTypeSchema,
|
||||
toolProfileBindingTargetTypeSchema,
|
||||
toolProfileDefaultActionSchema,
|
||||
toolProfileEntryEffectSchema,
|
||||
toolProfileEntrySelectorTypeSchema,
|
||||
toolProfileStatusSchema,
|
||||
toolRateLimitWindowKindSchema,
|
||||
toolRedactedValueSummarySchema,
|
||||
toolRiskLevelSchema,
|
||||
toolRuntimeKindSchema,
|
||||
toolRuntimeSlotStatusSchema,
|
||||
toolTransportConfigSchema,
|
||||
toolAccessSelectorSchema,
|
||||
toolPolicyConditionsSchema,
|
||||
toolRateLimitRuleSchema,
|
||||
toolTrustRuleArgumentFiltersSchema,
|
||||
toolTrustRuleBatchApprovalSchema,
|
||||
toolTrustRuleScopeSchema,
|
||||
toolConnectionTestCallSchema,
|
||||
connectionTokenIssuancePathSchema,
|
||||
connectionTokenRequestSchema,
|
||||
connectionTokenScopeSchema,
|
||||
createToolTrustRuleFromActionRequestSchema,
|
||||
revokeToolTrustRuleSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
importMcpJsonSchema,
|
||||
mcpConnectionCredentialRefSchema,
|
||||
createToolApplicationSchema,
|
||||
connectToolAppSchema,
|
||||
reconnectToolAppSchema,
|
||||
finishToolAppSchema,
|
||||
updateToolApplicationSchema,
|
||||
createToolConnectionSchema,
|
||||
createToolMcpGatewaySchema,
|
||||
createToolMcpGatewayTokenSchema,
|
||||
createToolStdioCommandTemplateSchema,
|
||||
disableToolStdioCommandTemplateSchema,
|
||||
updateToolConnectionSchema,
|
||||
putToolConnectionInstallsSchema,
|
||||
updateToolMcpGatewaySchema,
|
||||
createToolInvocationSchema,
|
||||
createToolPolicySchema,
|
||||
createToolProfileBindingForProfileSchema,
|
||||
createToolProfileBindingSchema,
|
||||
createToolProfileEntryForProfileSchema,
|
||||
createToolProfileEntrySchema,
|
||||
createToolProfileSchema,
|
||||
createToolProfileWithEntriesSchema,
|
||||
deleteToolProfileSchema,
|
||||
duplicateToolPolicySchema,
|
||||
duplicateToolProfileSchema,
|
||||
reorderToolPoliciesSchema,
|
||||
reviewToolProfileNewToolsSchema,
|
||||
unbindToolProfileBindingSchema,
|
||||
updateToolPolicySchema,
|
||||
updateToolProfileEntrySchema,
|
||||
updateToolProfileSchema,
|
||||
updateToolProfileWithEntriesSchema,
|
||||
upsertToolCatalogEntrySchema,
|
||||
type CreateToolActionRequest,
|
||||
type ConnectToolApp,
|
||||
type ReconnectToolApp,
|
||||
type CreateToolApplication,
|
||||
type FinishToolApp,
|
||||
type UpdateToolApplication,
|
||||
type CreateToolConnection,
|
||||
type CreateToolMcpGateway,
|
||||
type CreateToolMcpGatewayToken,
|
||||
type CreateToolStdioCommandTemplate,
|
||||
type DisableToolStdioCommandTemplate,
|
||||
type UpdateToolConnection,
|
||||
type PutToolConnectionInstalls,
|
||||
type UpdateToolMcpGateway,
|
||||
type CreateToolInvocation,
|
||||
type CreateToolPolicy,
|
||||
type CreateToolProfile,
|
||||
type CreateToolProfileBinding,
|
||||
type CreateToolProfileBindingForProfile,
|
||||
type CreateToolProfileEntry,
|
||||
type CreateToolProfileEntryForProfile,
|
||||
type CreateToolProfileWithEntries,
|
||||
type DeleteToolProfile,
|
||||
type DuplicateToolPolicy,
|
||||
type DuplicateToolProfile,
|
||||
type ImportMcpJson,
|
||||
type ReviewToolProfileNewTools,
|
||||
type ReorderToolPolicies,
|
||||
type UpdateToolPolicy,
|
||||
type UpdateToolProfileEntry,
|
||||
type UpdateToolProfile,
|
||||
type UpdateToolProfileWithEntries,
|
||||
type UnbindToolProfileBinding,
|
||||
type UpsertToolCatalogEntry,
|
||||
type ConnectionTokenRequestInput,
|
||||
type ToolPolicyTestRequestInput,
|
||||
type CreateToolTrustRuleFromActionRequest,
|
||||
type RevokeToolTrustRule,
|
||||
} from "./tool-access.js";
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ describe("instance experimental settings validators", () => {
|
|||
expect(settings.enableBuiltInAgents).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults apps off", () => {
|
||||
const settings = instanceExperimentalSettingsSchema.parse({});
|
||||
|
||||
expect(settings.enableApps).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts worktree run execution patches", () => {
|
||||
expect(
|
||||
patchInstanceExperimentalSettingsSchema.parse({
|
||||
|
|
@ -117,4 +123,14 @@ describe("instance experimental settings validators", () => {
|
|||
enableBuiltInAgents: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts apps patches", () => {
|
||||
expect(
|
||||
patchInstanceExperimentalSettingsSchema.parse({
|
||||
enableApps: true,
|
||||
}),
|
||||
).toEqual({
|
||||
enableApps: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableEnvironments: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableStreamlinedLeftNavigation: z.boolean().default(true),
|
||||
enableApps: z.boolean().default(false),
|
||||
enablePipelines: z.boolean().default(false),
|
||||
enableCases: z.boolean().default(false),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
|
|
@ -50,6 +51,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableExperimentalFileViewer: z.boolean().default(false),
|
||||
enableCloudSync: z.boolean().default(false),
|
||||
enableExternalObjects: z.boolean().default(false),
|
||||
enableSmokeLab: z.boolean().default(false),
|
||||
enableBuiltInAgents: z.boolean().default(false),
|
||||
enableDecisions: z.boolean().default(false),
|
||||
enableGoalsSidebarLink: z.boolean().default(false),
|
||||
|
|
|
|||
|
|
@ -742,6 +742,22 @@ export const requestConfirmationTargetSchema = z.discriminatedUnion("type", [
|
|||
requestConfirmationCustomTargetSchema,
|
||||
]);
|
||||
|
||||
export const requestConfirmationToolActionPayloadSchema = z.object({
|
||||
version: z.literal(1),
|
||||
actionRequestId: z.string().uuid(),
|
||||
invocationId: z.string().uuid(),
|
||||
toolName: z.string().trim().min(1).max(500),
|
||||
toolDisplayName: z.string().trim().min(1).max(500),
|
||||
connectionId: z.string().uuid().nullable(),
|
||||
applicationId: z.string().uuid().nullable(),
|
||||
appDisplayName: z.string().trim().min(1).max(500).nullable(),
|
||||
risk: z.enum(["write", "destructive"]),
|
||||
previewMarkdown: z.string().trim().min(1).max(20000),
|
||||
argumentsSummaryJson: z.string().max(20000),
|
||||
argumentsHash: z.string().trim().min(1).max(255),
|
||||
expiresAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
export const requestConfirmationPayloadSchema = z.object({
|
||||
version: z.literal(1),
|
||||
prompt: z.string().trim().min(1).max(1000),
|
||||
|
|
@ -754,6 +770,7 @@ export const requestConfirmationPayloadSchema = z.object({
|
|||
detailsMarkdown: z.string().max(20000).nullable().optional(),
|
||||
supersedeOnUserComment: z.boolean().optional(),
|
||||
target: requestConfirmationTargetSchema.nullable().optional(),
|
||||
toolAction: requestConfirmationToolActionPayloadSchema.optional(),
|
||||
});
|
||||
|
||||
export const requestCheckboxConfirmationOptionSchema = z.object({
|
||||
|
|
@ -867,6 +884,19 @@ export const requestConfirmationResumeFailureSchema = z.object({
|
|||
updatedAt: z.string().trim().min(1).nullable().optional(),
|
||||
});
|
||||
|
||||
export const requestConfirmationToolActionResultSchema = z.object({
|
||||
version: z.literal(1),
|
||||
status: z.enum(["approved", "executing", "executed", "failed", "expired"]),
|
||||
errorCode: z.string().trim().min(1).max(120).nullable().optional(),
|
||||
errorMessage: z.string().trim().min(1).max(4000).nullable().optional(),
|
||||
// Populated on `executed` so the card can report the outcome (e.g. "Row 42
|
||||
// added") instead of a bare checkmark, with an optional deep-link when the
|
||||
// connector returns a URL (PAP-13745 §5 Executed / Peak-End).
|
||||
resultSummary: z.string().trim().min(1).max(4000).nullable().optional(),
|
||||
resultHref: z.string().trim().url().max(2000).nullable().optional(),
|
||||
updatedAt: z.string().datetime({ offset: true }),
|
||||
});
|
||||
|
||||
export const requestConfirmationResultSchema = z.object({
|
||||
version: z.literal(1),
|
||||
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
|
||||
|
|
@ -874,6 +904,7 @@ export const requestConfirmationResultSchema = z.object({
|
|||
commentId: z.string().uuid().nullable().optional(),
|
||||
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
|
||||
resumeFailure: requestConfirmationResumeFailureSchema.nullable().optional(),
|
||||
toolAction: requestConfirmationToolActionResultSchema.optional(),
|
||||
});
|
||||
|
||||
export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({
|
||||
|
|
|
|||
|
|
@ -1135,25 +1135,27 @@ export const installPluginSchema = z.object({
|
|||
export type InstallPlugin = z.infer<typeof installPluginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin config (instance configuration) schemas
|
||||
// Plugin config (company-scoped configuration) schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for creating or updating a plugin's instance configuration.
|
||||
* Schema for creating or updating a plugin's company-scoped configuration.
|
||||
* configJson is validated permissively here; runtime validation against
|
||||
* the plugin's instanceConfigSchema is done at the service layer.
|
||||
*/
|
||||
export const upsertPluginConfigSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
configJson: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>;
|
||||
|
||||
/**
|
||||
* Schema for partially updating a plugin's instance configuration.
|
||||
* Schema for partially updating a plugin's company-scoped configuration.
|
||||
* Allows a partial merge of config values.
|
||||
*/
|
||||
export const patchPluginConfigSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
configJson: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||
import {
|
||||
SECRET_BINDING_TARGET_TYPES,
|
||||
SECRET_MANAGED_MODES,
|
||||
SECRET_PROJECTION_CLASSES,
|
||||
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||
SECRET_PROVIDERS,
|
||||
SECRET_STATUSES,
|
||||
|
|
@ -20,6 +21,8 @@ export const envBindingSecretRefSchema = z.object({
|
|||
type: z.literal("secret_ref"),
|
||||
secretId: z.string().uuid(),
|
||||
version: secretVersionSelectorSchema.optional(),
|
||||
projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(),
|
||||
projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(),
|
||||
});
|
||||
|
||||
export const envBindingUserSecretRefSchema = z.object({
|
||||
|
|
@ -135,6 +138,8 @@ export const createSecretBindingSchema = secretBindingTargetSchema.extend({
|
|||
versionSelector: secretVersionSelectorSchema.default("latest"),
|
||||
required: z.boolean().default(true),
|
||||
label: z.string().optional().nullable(),
|
||||
projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(),
|
||||
projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateSecretBinding = z.infer<typeof createSecretBindingSchema>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
SMOKE_RUN_STATUSES,
|
||||
SMOKE_RUN_STEP_PATHS,
|
||||
SMOKE_RUN_STEP_STATUSES,
|
||||
SMOKE_RUN_TRIGGERS,
|
||||
} from "../types/smoke-lab.js";
|
||||
|
||||
export const smokeRunTriggerSchema = z.enum(SMOKE_RUN_TRIGGERS);
|
||||
export const smokeRunStatusSchema = z.enum(SMOKE_RUN_STATUSES);
|
||||
export const smokeRunStepPathSchema = z.enum(SMOKE_RUN_STEP_PATHS);
|
||||
export const smokeRunStepStatusSchema = z.enum(SMOKE_RUN_STEP_STATUSES);
|
||||
|
||||
export const createSmokeRunSchema = z.object({
|
||||
trigger: smokeRunTriggerSchema.default("manual"),
|
||||
summary: z.record(z.string(), z.unknown()).default({}),
|
||||
}).strict();
|
||||
|
||||
export const updateSmokeRunSchema = z.object({
|
||||
status: smokeRunStatusSchema,
|
||||
summary: z.record(z.string(), z.unknown()).optional(),
|
||||
}).strict();
|
||||
|
||||
export const recordSmokeRunStepSchema = z.object({
|
||||
path: smokeRunStepPathSchema,
|
||||
scenarioStep: z.string().min(1).max(200),
|
||||
status: smokeRunStepStatusSchema,
|
||||
detail: z.string().max(4_000).nullable().optional(),
|
||||
screenshotArtifactRef: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
durationMs: z.number().int().min(0).max(24 * 60 * 60 * 1000).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
export type CreateSmokeRun = z.infer<typeof createSmokeRunSchema>;
|
||||
export type UpdateSmokeRun = z.infer<typeof updateSmokeRunSchema>;
|
||||
export type RecordSmokeRunStep = z.infer<typeof recordSmokeRunStepSchema>;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createToolConnectionSchema,
|
||||
toolRedactedValueSummarySchema,
|
||||
toolTransportConfigSchema,
|
||||
} from "./tool-access.js";
|
||||
|
||||
describe("tool access validators", () => {
|
||||
it("rejects raw credential-looking fields in transport config", () => {
|
||||
const parsed = toolTransportConfigSchema.safeParse({
|
||||
url: "https://example.test/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer raw-token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (!parsed.success) {
|
||||
expect(parsed.error.issues[0]?.message).toContain("credentialSecretRefs");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts secret references for connection credentials", () => {
|
||||
const parsed = createToolConnectionSchema.safeParse({
|
||||
applicationId: "11111111-1111-4111-8111-111111111111",
|
||||
name: "GitHub fixture",
|
||||
connectionKind: "managed",
|
||||
transportConfig: { url: "https://example.test/mcp" },
|
||||
credentialSecretRefs: [
|
||||
{
|
||||
secretId: "22222222-2222-4222-8222-222222222222",
|
||||
configPath: "headers.Authorization",
|
||||
versionSelector: "latest",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps invocation payload summaries redacted and bounded", () => {
|
||||
const parsed = toolRedactedValueSummarySchema.parse({
|
||||
summary: "Redacted arguments: 2 fields omitted.",
|
||||
sha256: "a".repeat(64),
|
||||
redactedFields: ["headers.Authorization", "body.token"],
|
||||
});
|
||||
|
||||
expect(parsed.redactedFields).toEqual(["headers.Authorization", "body.token"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,766 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
CONNECTION_TOKEN_ISSUANCE_PATHS,
|
||||
SECRET_PROJECTION_CLASSES,
|
||||
TOOL_ACTION_REQUEST_STATUSES,
|
||||
TOOL_APPLICATION_STATUSES,
|
||||
TOOL_APPLICATION_TYPES,
|
||||
TOOL_AUDIT_EVENT_TYPES,
|
||||
TOOL_AUDIT_OUTCOMES,
|
||||
TOOL_CATALOG_ENTRY_KINDS,
|
||||
TOOL_CATALOG_ENTRY_STATUSES,
|
||||
TOOL_CONNECTION_HEALTH_STATUSES,
|
||||
TOOL_CONNECTION_KINDS,
|
||||
TOOL_INVOCATION_APPROVAL_STATES,
|
||||
TOOL_INVOCATION_STATUSES,
|
||||
TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES,
|
||||
TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES,
|
||||
TOOL_MCP_GATEWAY_STATUSES,
|
||||
TOOL_MCP_GATEWAY_TOKEN_ACTIONS,
|
||||
TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES,
|
||||
TOOL_POLICY_DECISIONS,
|
||||
TOOL_POLICY_TYPES,
|
||||
TOOL_PROFILE_BINDING_TARGET_TYPES,
|
||||
TOOL_PROFILE_DEFAULT_ACTIONS,
|
||||
TOOL_PROFILE_ENTRY_EFFECTS,
|
||||
TOOL_PROFILE_ENTRY_SELECTOR_TYPES,
|
||||
TOOL_PROFILE_STATUSES,
|
||||
TOOL_RATE_LIMIT_WINDOW_KINDS,
|
||||
TOOL_RISK_LEVELS,
|
||||
TOOL_RUNTIME_KINDS,
|
||||
TOOL_RUNTIME_SLOT_STATUSES,
|
||||
} from "../constants.js";
|
||||
import { jsonSchemaSchema } from "./plugin.js";
|
||||
|
||||
export const toolApplicationTypeSchema = z.enum(TOOL_APPLICATION_TYPES);
|
||||
export const toolApplicationStatusSchema = z.enum(TOOL_APPLICATION_STATUSES);
|
||||
export const toolConnectionTransportSchema = z.enum(["remote_http", "local_stdio"]);
|
||||
export const toolConnectionStatusSchema = z.enum(["draft", "active", "disabled", "archived"]);
|
||||
export const toolConnectionInstallTargetTypeSchema = z.enum(["company", "agent"]);
|
||||
export const toolCredentialPlacementSchema = z.enum(["header", "env"]);
|
||||
export const toolConnectionKindSchema = z.enum(TOOL_CONNECTION_KINDS);
|
||||
export const toolConnectionHealthStatusSchema = z.enum(TOOL_CONNECTION_HEALTH_STATUSES);
|
||||
export const toolCatalogEntryKindSchema = z.enum(TOOL_CATALOG_ENTRY_KINDS);
|
||||
export const toolCatalogEntryStatusSchema = z.enum(TOOL_CATALOG_ENTRY_STATUSES);
|
||||
export const toolRiskLevelSchema = z.enum(TOOL_RISK_LEVELS);
|
||||
export const toolProfileStatusSchema = z.enum(TOOL_PROFILE_STATUSES);
|
||||
export const toolProfileDefaultActionSchema = z.enum(TOOL_PROFILE_DEFAULT_ACTIONS);
|
||||
export const toolProfileEntrySelectorTypeSchema = z.enum(TOOL_PROFILE_ENTRY_SELECTOR_TYPES);
|
||||
export const toolProfileEntryEffectSchema = z.enum(TOOL_PROFILE_ENTRY_EFFECTS);
|
||||
export const toolProfileBindingTargetTypeSchema = z.enum(TOOL_PROFILE_BINDING_TARGET_TYPES);
|
||||
export const toolPolicyTypeSchema = z.enum(TOOL_POLICY_TYPES);
|
||||
export const toolPolicyDecisionSchema = z.enum(TOOL_POLICY_DECISIONS);
|
||||
export const toolInvocationStatusSchema = z.enum(TOOL_INVOCATION_STATUSES);
|
||||
export const toolInvocationApprovalStateSchema = z.enum(TOOL_INVOCATION_APPROVAL_STATES);
|
||||
export const toolMcpGatewayStatusSchema = z.enum(TOOL_MCP_GATEWAY_STATUSES);
|
||||
export const toolMcpGatewayDefaultProfileModeSchema = z.enum(TOOL_MCP_GATEWAY_DEFAULT_PROFILE_MODES);
|
||||
export const toolMcpGatewayContextScopeTypeSchema = z.enum(TOOL_MCP_GATEWAY_CONTEXT_SCOPE_TYPES);
|
||||
export const toolMcpGatewayTokenSubjectTypeSchema = z.enum(TOOL_MCP_GATEWAY_TOKEN_SUBJECT_TYPES);
|
||||
export const toolMcpGatewayTokenActionSchema = z.enum(TOOL_MCP_GATEWAY_TOKEN_ACTIONS);
|
||||
export const toolActionRequestStatusSchema = z.enum(TOOL_ACTION_REQUEST_STATUSES);
|
||||
export const toolAuditEventTypeSchema = z.enum(TOOL_AUDIT_EVENT_TYPES);
|
||||
export const toolAuditOutcomeSchema = z.enum(TOOL_AUDIT_OUTCOMES);
|
||||
export const toolRuntimeKindSchema = z.enum(TOOL_RUNTIME_KINDS);
|
||||
export const toolRuntimeSlotStatusSchema = z.enum(TOOL_RUNTIME_SLOT_STATUSES);
|
||||
export const toolRateLimitWindowKindSchema = z.enum(TOOL_RATE_LIMIT_WINDOW_KINDS);
|
||||
|
||||
const safeKeyPattern = /^[a-z0-9][a-z0-9._:-]*$/i;
|
||||
const sensitiveConfigKeyPattern =
|
||||
/^(access[-_]?key([-_]?id)?|api[-_]?key|authorization|bearer|client[-_]?secret|credential|credentials|jwt|password|passwd|private[-_]?key|refresh[-_]?token|secret|secret[-_]?access[-_]?key|secret[-_]?key|session[-_]?token|token)$/i;
|
||||
|
||||
function rejectSensitiveConfigKeys(value: unknown, ctx: z.RefinementCtx, path: Array<string | number> = []) {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry, index) => rejectSensitiveConfigKeys(entry, ctx, [...path, index]));
|
||||
return;
|
||||
}
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (sensitiveConfigKeyPattern.test(key)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [...path, key],
|
||||
message: `Tool access config cannot persist sensitive field: ${key}. Use credentialSecretRefs instead.`,
|
||||
});
|
||||
}
|
||||
rejectSensitiveConfigKeys(nested, ctx, [...path, key]);
|
||||
}
|
||||
}
|
||||
|
||||
export const toolCredentialSecretRefSchema = z.object({
|
||||
secretId: z.string().uuid(),
|
||||
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).optional(),
|
||||
configPath: z.string().trim().min(1).max(200),
|
||||
required: z.boolean().optional(),
|
||||
label: z.string().trim().max(120).optional().nullable(),
|
||||
projectionClass: z.enum(SECRET_PROJECTION_CLASSES).optional(),
|
||||
projectionAllowlistKey: z.string().trim().min(1).max(160).optional().nullable(),
|
||||
});
|
||||
|
||||
export const mcpConnectionCredentialRefSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
secretId: z.string().uuid(),
|
||||
version: z.union([z.literal("latest"), z.number().int().positive()]).optional(),
|
||||
placement: toolCredentialPlacementSchema,
|
||||
key: z.string().trim().min(1).max(160),
|
||||
prefix: z.string().max(120).nullable().optional(),
|
||||
});
|
||||
|
||||
export const toolTransportConfigSchema = z.record(z.string(), z.unknown()).superRefine(rejectSensitiveConfigKeys);
|
||||
|
||||
export const toolRedactedValueSummarySchema = z.object({
|
||||
summary: z.string().max(4000),
|
||||
sizeBytes: z.number().int().min(0).optional().nullable(),
|
||||
sha256: z.string().trim().regex(/^[a-f0-9]{64}$/i).optional().nullable(),
|
||||
redactedFields: z.array(z.string().trim().min(1).max(200)).default([]).optional(),
|
||||
artifactId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export const createToolApplicationSchema = z.object({
|
||||
applicationKey: z.string().trim().min(1).max(160).regex(safeKeyPattern).optional(),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
type: toolApplicationTypeSchema,
|
||||
status: toolApplicationStatusSchema.optional(),
|
||||
pluginId: z.string().uuid().optional().nullable(),
|
||||
ownerAgentId: z.string().uuid().optional().nullable(),
|
||||
ownerUserId: z.string().optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolApplication = z.infer<typeof createToolApplicationSchema>;
|
||||
|
||||
export const updateToolApplicationSchema = createToolApplicationSchema.partial().refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
{ message: "At least one tool application field is required" },
|
||||
);
|
||||
|
||||
export type UpdateToolApplication = z.infer<typeof updateToolApplicationSchema>;
|
||||
|
||||
export const createToolConnectionSchema = z.object({
|
||||
applicationId: z.string().uuid().optional(),
|
||||
applicationName: z.string().trim().min(1).max(160).optional(),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
transport: toolConnectionTransportSchema.optional(),
|
||||
status: toolConnectionStatusSchema.optional(),
|
||||
connectionKind: toolConnectionKindSchema.default("managed"),
|
||||
config: toolTransportConfigSchema.optional(),
|
||||
transportConfig: toolTransportConfigSchema.default({}),
|
||||
credentialRefs: z.array(mcpConnectionCredentialRefSchema).optional(),
|
||||
credentialSecretRefs: z.array(toolCredentialSecretRefSchema).default([]),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateToolConnection = z.infer<typeof createToolConnectionSchema>;
|
||||
|
||||
export const updateToolConnectionSchema = createToolConnectionSchema.omit({ applicationId: true }).partial().refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
{ message: "At least one tool connection field is required" },
|
||||
);
|
||||
|
||||
export type UpdateToolConnection = z.infer<typeof updateToolConnectionSchema>;
|
||||
|
||||
export const putToolConnectionInstallsSchema = z.object({
|
||||
installs: z.array(z.object({
|
||||
targetType: toolConnectionInstallTargetTypeSchema,
|
||||
targetId: z.string().trim().min(1).max(200),
|
||||
})).max(1000),
|
||||
}).strict();
|
||||
|
||||
export type PutToolConnectionInstalls = z.infer<typeof putToolConnectionInstallsSchema>;
|
||||
|
||||
export const connectionTokenIssuancePathSchema = z.enum(CONNECTION_TOKEN_ISSUANCE_PATHS);
|
||||
|
||||
export const connectionTokenScopeSchema = z.union([
|
||||
z.string().trim().min(1).max(500),
|
||||
z.array(z.string().trim().min(1).max(240)).max(100),
|
||||
]);
|
||||
|
||||
export const connectionTokenRequestSchema = z.object({
|
||||
scope: connectionTokenScopeSchema.optional(),
|
||||
requestedTtlSeconds: z.number().int().positive().max(86_400).optional(),
|
||||
}).strict();
|
||||
|
||||
export type ConnectionTokenRequestInput = z.infer<typeof connectionTokenRequestSchema>;
|
||||
|
||||
const envKeyPattern = /^[A-Z_][A-Z0-9_]*$/i;
|
||||
|
||||
export const toolStdioTemplateToolSchema = z.object({
|
||||
name: z.string().trim().min(1).max(240),
|
||||
title: z.string().trim().max(240).optional().nullable(),
|
||||
description: z.string().max(8000).optional().nullable(),
|
||||
inputSchema: jsonSchemaSchema.optional().nullable(),
|
||||
annotations: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export const createToolStdioCommandTemplateSchema = z.object({
|
||||
templateId: z.string().trim().min(1).max(160).regex(safeKeyPattern),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
command: z.string().trim().min(1).max(2000),
|
||||
args: z.array(z.string().max(2000)).max(100).default([]),
|
||||
envKeys: z.array(z.string().trim().min(1).max(160).regex(envKeyPattern)).max(200).default([]),
|
||||
tools: z.array(toolStdioTemplateToolSchema).max(500).default([]),
|
||||
});
|
||||
|
||||
export type CreateToolStdioCommandTemplate = z.infer<typeof createToolStdioCommandTemplateSchema>;
|
||||
|
||||
export const disableToolStdioCommandTemplateSchema = z.object({
|
||||
reason: z.string().trim().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type DisableToolStdioCommandTemplate = z.infer<typeof disableToolStdioCommandTemplateSchema>;
|
||||
|
||||
export const connectToolAppSchema = z.object({
|
||||
galleryKey: z.string().trim().min(1).max(120).optional(),
|
||||
link: z.string().trim().url().max(2000).optional(),
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)).optional(),
|
||||
configValues: z.record(z.string().trim().min(1).max(200), z.unknown()).optional(),
|
||||
applicationId: z.string().uuid().optional(),
|
||||
}).refine(
|
||||
(value) => Boolean(value.galleryKey) !== Boolean(value.link),
|
||||
{ message: "Provide exactly one of galleryKey or link" },
|
||||
);
|
||||
|
||||
export type ConnectToolApp = z.infer<typeof connectToolAppSchema>;
|
||||
|
||||
export const reconnectToolAppSchema = z.object({
|
||||
credentialValues: z.record(z.string().trim().min(1).max(200), z.string().min(1)),
|
||||
});
|
||||
|
||||
export type ReconnectToolApp = z.infer<typeof reconnectToolAppSchema>;
|
||||
|
||||
export const finishToolAppSchema = z.object({
|
||||
enabledCatalogEntryIds: z.array(z.string().uuid()).max(500).default([]),
|
||||
askFirstCatalogEntryIds: z.array(z.string().uuid()).max(500).default([]),
|
||||
access: z.union([
|
||||
z.literal("all_agents"),
|
||||
z.object({ agentIds: z.array(z.string().uuid()).min(1).max(250) }),
|
||||
]),
|
||||
});
|
||||
|
||||
export type FinishToolApp = z.infer<typeof finishToolAppSchema>;
|
||||
|
||||
export const upsertToolCatalogEntrySchema = z.object({
|
||||
applicationId: z.string().uuid(),
|
||||
connectionId: z.string().uuid(),
|
||||
entryKind: toolCatalogEntryKindSchema.default("tool"),
|
||||
toolName: z.string().trim().min(1).max(240),
|
||||
title: z.string().trim().max(240).optional().nullable(),
|
||||
description: z.string().max(8000).optional().nullable(),
|
||||
inputSchema: jsonSchemaSchema.optional().nullable(),
|
||||
outputSchema: jsonSchemaSchema.optional().nullable(),
|
||||
annotations: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
riskLevel: toolRiskLevelSchema.default("medium"),
|
||||
isReadOnly: z.boolean().default(false),
|
||||
isWrite: z.boolean().default(false),
|
||||
isDestructive: z.boolean().default(false),
|
||||
status: toolCatalogEntryStatusSchema.default("active"),
|
||||
version: z.string().trim().max(200).optional().nullable(),
|
||||
schemaHash: z.string().trim().max(128).optional().nullable(),
|
||||
});
|
||||
|
||||
export type UpsertToolCatalogEntry = z.infer<typeof upsertToolCatalogEntrySchema>;
|
||||
|
||||
export const createToolProfileSchema = z.object({
|
||||
profileKey: z.string().trim().min(1).max(160).regex(safeKeyPattern),
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
status: toolProfileStatusSchema.default("active"),
|
||||
defaultAction: toolProfileDefaultActionSchema.default("deny"),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolProfile = z.infer<typeof createToolProfileSchema>;
|
||||
|
||||
export const updateToolProfileSchema = createToolProfileSchema.partial().refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
{ message: "At least one tool profile field is required" },
|
||||
);
|
||||
|
||||
export type UpdateToolProfile = z.infer<typeof updateToolProfileSchema>;
|
||||
|
||||
export const createToolProfileEntrySchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
selectorType: toolProfileEntrySelectorTypeSchema,
|
||||
effect: toolProfileEntryEffectSchema.default("include"),
|
||||
applicationId: z.string().uuid().optional().nullable(),
|
||||
connectionId: z.string().uuid().optional().nullable(),
|
||||
catalogEntryId: z.string().uuid().optional().nullable(),
|
||||
toolName: z.string().trim().min(1).max(240).optional().nullable(),
|
||||
riskLevel: toolRiskLevelSchema.optional().nullable(),
|
||||
conditions: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolProfileEntry = z.infer<typeof createToolProfileEntrySchema>;
|
||||
|
||||
export const createToolProfileEntryForProfileSchema = createToolProfileEntrySchema.omit({ profileId: true });
|
||||
|
||||
export type CreateToolProfileEntryForProfile = z.infer<typeof createToolProfileEntryForProfileSchema>;
|
||||
|
||||
export const updateToolProfileEntrySchema = createToolProfileEntryForProfileSchema.partial().refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
{ message: "At least one tool profile entry field is required" },
|
||||
);
|
||||
|
||||
export type UpdateToolProfileEntry = z.infer<typeof updateToolProfileEntrySchema>;
|
||||
|
||||
export const createToolProfileWithEntriesSchema = createToolProfileSchema.extend({
|
||||
entries: z.array(createToolProfileEntryForProfileSchema).max(250).optional(),
|
||||
});
|
||||
|
||||
export type CreateToolProfileWithEntries = z.infer<typeof createToolProfileWithEntriesSchema>;
|
||||
|
||||
export const duplicateToolProfileSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
includeAssignments: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type DuplicateToolProfile = z.infer<typeof duplicateToolProfileSchema>;
|
||||
|
||||
export const updateToolProfileWithEntriesSchema = createToolProfileSchema.partial().extend({
|
||||
entries: z.array(createToolProfileEntryForProfileSchema).max(250).optional(),
|
||||
}).refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
{ message: "At least one tool profile field is required" },
|
||||
);
|
||||
|
||||
export type UpdateToolProfileWithEntries = z.infer<typeof updateToolProfileWithEntriesSchema>;
|
||||
|
||||
export const reviewToolProfileNewToolsSchema = z.object({
|
||||
decisions: z.array(z.object({
|
||||
catalogEntryId: z.string().uuid(),
|
||||
decision: z.enum(["allow", "keep_blocked"]),
|
||||
})).min(1).max(250),
|
||||
});
|
||||
|
||||
export type ReviewToolProfileNewTools = z.infer<typeof reviewToolProfileNewToolsSchema>;
|
||||
|
||||
export const deleteToolProfileSchema = z.object({
|
||||
force: z.boolean().default(false),
|
||||
reassignToProfileId: z.string().uuid().optional(),
|
||||
}).default({});
|
||||
|
||||
export type DeleteToolProfile = z.infer<typeof deleteToolProfileSchema>;
|
||||
|
||||
export const createToolProfileBindingSchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
targetType: toolProfileBindingTargetTypeSchema,
|
||||
targetId: z.string().trim().min(1).max(200),
|
||||
priority: z.number().int().min(0).max(10000).default(100),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolProfileBinding = z.infer<typeof createToolProfileBindingSchema>;
|
||||
|
||||
export const createToolProfileBindingForProfileSchema = createToolProfileBindingSchema.omit({ profileId: true });
|
||||
|
||||
export type CreateToolProfileBindingForProfile = z.infer<typeof createToolProfileBindingForProfileSchema>;
|
||||
|
||||
export const unbindToolProfileBindingSchema = createToolProfileBindingForProfileSchema.pick({
|
||||
targetType: true,
|
||||
targetId: true,
|
||||
});
|
||||
|
||||
export type UnbindToolProfileBinding = z.infer<typeof unbindToolProfileBindingSchema>;
|
||||
|
||||
const headerNameSchema = z.string().trim().min(1).max(120).regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/);
|
||||
|
||||
export const toolMcpGatewayAuthConfigSchema = z.object({
|
||||
version: z.literal(1).default(1),
|
||||
bearer: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
tokenPrefix: z.literal("pcgw").default("pcgw"),
|
||||
defaultTtlSeconds: z.number().int().positive().max(31_536_000).nullable().default(7_776_000),
|
||||
requireFiniteExpiry: z.boolean().default(true),
|
||||
longLivedTokenRequiresOverride: z.boolean().default(true),
|
||||
}).default({}),
|
||||
oauth: z.object({
|
||||
enabled: z.literal(false).default(false),
|
||||
reservedFor: z.literal("v1_5").default("v1_5"),
|
||||
protectedResourceMetadataPath: z.string().trim().max(240).optional().nullable(),
|
||||
dynamicClientRegistration: z.literal(false).optional(),
|
||||
authorizationCodePkce: z.literal(false).optional(),
|
||||
}).default({}),
|
||||
});
|
||||
|
||||
export const toolMcpGatewayHeaderPolicySchema = z.object({
|
||||
version: z.literal(1).default(1),
|
||||
callerPassthrough: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
allowedHeaders: z.array(headerNameSchema).max(50).default([]),
|
||||
}).default({}),
|
||||
staticHeaders: z.array(z.object({
|
||||
name: headerNameSchema,
|
||||
valueRef: z.string().trim().max(240).optional().nullable(),
|
||||
value: z.string().max(4000).optional().nullable(),
|
||||
})).max(50).default([]),
|
||||
generatedMetadata: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
allowedHeaders: z.array(headerNameSchema).max(20).default([]),
|
||||
}).default({}),
|
||||
responseHeaders: z.object({
|
||||
forwardMcpRequiredHeaders: z.boolean().default(true),
|
||||
forwardSafeCacheHeaders: z.boolean().default(true),
|
||||
}).default({}),
|
||||
});
|
||||
|
||||
export const toolMcpGatewayMetadataPolicySchema = z.object({
|
||||
version: z.literal(1).default(1),
|
||||
forwardCompanyId: z.boolean().default(false),
|
||||
forwardGatewayId: z.boolean().default(false),
|
||||
forwardProjectId: z.boolean().default(false),
|
||||
forwardIssueId: z.boolean().default(false),
|
||||
forwardAgentId: z.boolean().default(false),
|
||||
forwardRunId: z.boolean().default(false),
|
||||
forwardCorrelationId: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const toolMcpGatewayOnDemandToolsConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
searchToolName: z.literal("search_tools").default("search_tools"),
|
||||
runToolName: z.literal("run_tool").default("run_tool"),
|
||||
});
|
||||
|
||||
export const createToolMcpGatewaySchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
slug: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional(),
|
||||
displaySlug: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
profileId: z.string().uuid(),
|
||||
defaultProfileMode: toolMcpGatewayDefaultProfileModeSchema.default("gateway_only").optional(),
|
||||
contextScopeType: toolMcpGatewayContextScopeTypeSchema.default("none").optional(),
|
||||
contextScopeId: z.string().trim().min(1).max(200).optional().nullable(),
|
||||
agentId: z.string().uuid().optional().nullable(),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
issueId: z.string().uuid().optional().nullable(),
|
||||
approvalIssueId: z.string().uuid().optional().nullable(),
|
||||
authConfig: toolMcpGatewayAuthConfigSchema.optional(),
|
||||
headerPolicy: toolMcpGatewayHeaderPolicySchema.optional(),
|
||||
metadataPolicy: toolMcpGatewayMetadataPolicySchema.optional(),
|
||||
onDemandToolsConfig: toolMcpGatewayOnDemandToolsConfigSchema.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolMcpGateway = z.infer<typeof createToolMcpGatewaySchema>;
|
||||
|
||||
export const updateToolMcpGatewaySchema = createToolMcpGatewaySchema
|
||||
.partial()
|
||||
.extend({ status: toolMcpGatewayStatusSchema.optional() })
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "At least one gateway field is required" });
|
||||
|
||||
export type UpdateToolMcpGateway = z.infer<typeof updateToolMcpGatewaySchema>;
|
||||
|
||||
export const createToolMcpGatewayTokenSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
subjectType: toolMcpGatewayTokenSubjectTypeSchema.default("gateway_client").optional(),
|
||||
subjectId: z.string().trim().min(1).max(240).optional().nullable(),
|
||||
clientLabel: z.string().trim().min(1).max(160),
|
||||
ownerNote: z.string().trim().min(1).max(1000),
|
||||
allowedActions: z.array(toolMcpGatewayTokenActionSchema).min(1).max(TOOL_MCP_GATEWAY_TOKEN_ACTIONS.length).default(["tools/list", "tools/call"]).optional(),
|
||||
expiresAt: z.coerce.date().optional().nullable(),
|
||||
expiryOverrideReason: z.string().trim().min(1).max(1000).optional().nullable(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.subjectType && value.subjectType !== "gateway_client") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["subjectType"],
|
||||
message: "Public V1 token minting only supports gateway_client subjects; heartbeat_run is runtime-managed, while board_user and agent are reserved for later OAuth/user-bound flows.",
|
||||
});
|
||||
}
|
||||
if (value.expiresAt === null && !value.expiryOverrideReason) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["expiryOverrideReason"],
|
||||
message: "Non-expiring gateway tokens require an override reason.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateToolMcpGatewayToken = z.infer<typeof createToolMcpGatewayTokenSchema>;
|
||||
|
||||
const argumentConditionSchema = z.object({
|
||||
fieldEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(),
|
||||
fieldNotEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(),
|
||||
fieldIn: z.record(z.string().trim().min(1).max(120), z.array(z.unknown()).min(1).max(100)).optional(),
|
||||
fieldMatches: z.record(z.string().trim().min(1).max(120), z.string().trim().min(1).max(500)).optional(),
|
||||
fieldExists: z.array(z.string().trim().min(1).max(120)).max(100).optional(),
|
||||
fieldAbsent: z.array(z.string().trim().min(1).max(120)).max(100).optional(),
|
||||
}).strict().refine(
|
||||
(value) => Object.values(value).some((nested) => Array.isArray(nested) ? nested.length > 0 : Boolean(nested && Object.keys(nested).length > 0)),
|
||||
{ message: "Argument conditions must include at least one field predicate" },
|
||||
);
|
||||
|
||||
const timeWindowConditionSchema = z.object({
|
||||
startAt: z.string().trim().datetime({ offset: true }).optional(),
|
||||
endAt: z.string().trim().datetime({ offset: true }).optional(),
|
||||
daysOfWeekUtc: z.array(z.number().int().min(0).max(6)).max(7).optional(),
|
||||
startHourUtc: z.number().int().min(0).max(23).optional(),
|
||||
endHourUtc: z.number().int().min(0).max(24).optional(),
|
||||
}).strict().refine(
|
||||
(value) => value.startAt || value.endAt || value.daysOfWeekUtc?.length || value.startHourUtc !== undefined || value.endHourUtc !== undefined,
|
||||
{ message: "timeWindow must include at least one bound" },
|
||||
);
|
||||
|
||||
const actorConditionSchema = z.object({
|
||||
actorType: z.enum(["agent", "user", "system", "plugin"]).optional(),
|
||||
actorTypes: z.array(z.enum(["agent", "user", "system", "plugin"])).max(20).optional(),
|
||||
agentId: z.string().uuid().optional(),
|
||||
agentIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
}).strict();
|
||||
|
||||
const contextConditionSchema = z.object({
|
||||
projectId: z.string().uuid().optional(),
|
||||
projectIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
routineId: z.string().uuid().optional(),
|
||||
routineIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
issueId: z.string().uuid().optional(),
|
||||
issueIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
requireIssue: z.boolean().optional(),
|
||||
requireProject: z.boolean().optional(),
|
||||
requireRoutine: z.boolean().optional(),
|
||||
}).strict();
|
||||
|
||||
const credentialScopeConditionSchema = z.object({
|
||||
applicationId: z.string().uuid().optional(),
|
||||
applicationIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
connectionId: z.string().uuid().optional(),
|
||||
connectionIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
catalogEntryId: z.string().uuid().optional(),
|
||||
catalogEntryIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
applicationKey: z.string().trim().min(1).max(160).optional(),
|
||||
applicationKeys: z.array(z.string().trim().min(1).max(160)).max(100).optional(),
|
||||
providerType: z.string().trim().min(1).max(160).optional(),
|
||||
providerTypes: z.array(z.string().trim().min(1).max(160)).max(100).optional(),
|
||||
}).strict();
|
||||
|
||||
export const toolPolicyConditionsSchema = z.object({
|
||||
arguments: argumentConditionSchema.optional(),
|
||||
args: argumentConditionSchema.optional(),
|
||||
actor: actorConditionSchema.optional(),
|
||||
context: contextConditionSchema.optional(),
|
||||
risk: z.object({
|
||||
levels: z.array(toolRiskLevelSchema).max(20).optional(),
|
||||
max: toolRiskLevelSchema.optional(),
|
||||
isWrite: z.boolean().optional(),
|
||||
isDestructive: z.boolean().optional(),
|
||||
}).strict().optional(),
|
||||
credentialScope: credentialScopeConditionSchema.optional(),
|
||||
trustBoundary: z.object({
|
||||
providerType: z.string().trim().min(1).max(160).optional(),
|
||||
providerTypes: z.array(z.string().trim().min(1).max(160)).max(100).optional(),
|
||||
applicationKey: z.string().trim().min(1).max(160).optional(),
|
||||
applicationKeys: z.array(z.string().trim().min(1).max(160)).max(100).optional(),
|
||||
remoteHttpOnly: z.boolean().optional(),
|
||||
paperclipSelfOnly: z.boolean().optional(),
|
||||
}).strict().optional(),
|
||||
timeWindow: timeWindowConditionSchema.optional(),
|
||||
}).strict().refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
{ message: "Tool policy conditions must include at least one supported condition group" },
|
||||
);
|
||||
|
||||
export const createToolPolicySchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
policyType: toolPolicyTypeSchema,
|
||||
priority: z.number().int().min(0).max(10000).default(100),
|
||||
enabled: z.boolean().default(true),
|
||||
selectors: z.record(z.string(), z.unknown()).default({}),
|
||||
conditions: toolPolicyConditionsSchema.optional().nullable(),
|
||||
config: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolPolicy = z.infer<typeof createToolPolicySchema>;
|
||||
|
||||
export const updateToolPolicySchema = createToolPolicySchema.partial().refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
{ message: "At least one tool policy field is required" },
|
||||
);
|
||||
|
||||
export type UpdateToolPolicy = z.infer<typeof updateToolPolicySchema>;
|
||||
|
||||
export const reorderToolPoliciesSchema = z.object({
|
||||
policyIds: z.array(z.string().uuid()).min(1).max(500),
|
||||
});
|
||||
|
||||
export type ReorderToolPolicies = z.infer<typeof reorderToolPoliciesSchema>;
|
||||
|
||||
export const duplicateToolPolicySchema = z.object({
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
});
|
||||
|
||||
export type DuplicateToolPolicy = z.infer<typeof duplicateToolPolicySchema>;
|
||||
|
||||
export const createToolInvocationSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(300).optional().nullable(),
|
||||
issueId: z.string().uuid().optional().nullable(),
|
||||
runId: z.string().uuid().optional().nullable(),
|
||||
applicationId: z.string().uuid().optional().nullable(),
|
||||
connectionId: z.string().uuid().optional().nullable(),
|
||||
catalogEntryId: z.string().uuid().optional().nullable(),
|
||||
toolName: z.string().trim().min(1).max(240),
|
||||
argumentsHash: z.string().trim().max(128).optional().nullable(),
|
||||
argumentsSummary: toolRedactedValueSummarySchema.optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolInvocation = z.infer<typeof createToolInvocationSchema>;
|
||||
|
||||
export const createToolActionRequestSchema = z.object({
|
||||
invocationId: z.string().uuid(),
|
||||
issueId: z.string().uuid().optional().nullable(),
|
||||
canonicalArgumentsHash: z.string().trim().min(1).max(128),
|
||||
canonicalArgumentsSummary: toolRedactedValueSummarySchema,
|
||||
signedArguments: z.string().trim().max(4096).optional().nullable(),
|
||||
previewMarkdown: z.string().max(20_000).optional().nullable(),
|
||||
expiresAt: z.coerce.date().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolActionRequest = z.infer<typeof createToolActionRequestSchema>;
|
||||
|
||||
export const toolConnectionTestCallSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
toolName: z.string().trim().min(1).max(240),
|
||||
parameters: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type ToolConnectionTestCallInput = z.infer<typeof toolConnectionTestCallSchema>;
|
||||
|
||||
export const importMcpJsonSchema = z.object({
|
||||
mcpJson: z.union([z.string(), z.record(z.string(), z.unknown())]),
|
||||
});
|
||||
|
||||
export type ImportMcpJson = z.infer<typeof importMcpJsonSchema>;
|
||||
|
||||
export const toolAccessSelectorSchema = z.object({
|
||||
actorType: z.enum(["agent", "user", "system", "plugin"]).optional(),
|
||||
agentId: z.string().uuid().optional(),
|
||||
agentIds: z.array(z.string().uuid()).optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
projectIds: z.array(z.string().uuid()).optional(),
|
||||
routineId: z.string().uuid().optional(),
|
||||
routineIds: z.array(z.string().uuid()).optional(),
|
||||
issueId: z.string().uuid().optional(),
|
||||
issueIds: z.array(z.string().uuid()).optional(),
|
||||
gatewayId: z.string().uuid().optional(),
|
||||
gatewayIds: z.array(z.string().uuid()).optional(),
|
||||
gatewayPublicId: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional(),
|
||||
gatewayPublicIds: z.array(z.string().trim().min(1).max(120).regex(safeKeyPattern)).optional(),
|
||||
gatewayTokenId: z.string().uuid().optional(),
|
||||
gatewayTokenIds: z.array(z.string().uuid()).optional(),
|
||||
clientSubjectType: toolMcpGatewayTokenSubjectTypeSchema.optional(),
|
||||
clientSubjectTypes: z.array(toolMcpGatewayTokenSubjectTypeSchema).optional(),
|
||||
clientName: z.string().trim().min(1).max(160).optional(),
|
||||
clientNames: z.array(z.string().trim().min(1).max(160)).optional(),
|
||||
externalClient: z.boolean().optional(),
|
||||
applicationId: z.string().uuid().optional(),
|
||||
applicationIds: z.array(z.string().uuid()).optional(),
|
||||
connectionId: z.string().uuid().optional(),
|
||||
connectionIds: z.array(z.string().uuid()).optional(),
|
||||
catalogEntryId: z.string().uuid().optional(),
|
||||
catalogEntryIds: z.array(z.string().uuid()).optional(),
|
||||
toolName: z.string().trim().min(1).max(240).optional(),
|
||||
toolNames: z.array(z.string().trim().min(1).max(240)).optional(),
|
||||
riskLevel: toolRiskLevelSchema.optional(),
|
||||
riskLevels: z.array(toolRiskLevelSchema).optional(),
|
||||
});
|
||||
|
||||
export const toolRateLimitRuleSchema = z.object({
|
||||
limit: z.number().int().positive().max(1_000_000),
|
||||
windowSeconds: z.number().int().positive().max(31_536_000),
|
||||
keyBy: z.array(z.enum(["company", "agent", "application", "connection", "tool"])).optional(),
|
||||
});
|
||||
|
||||
export const toolTrustRuleArgumentFiltersSchema = z.object({
|
||||
allowAny: z.boolean().optional(),
|
||||
exactHash: z.string().trim().regex(/^[a-f0-9]{64}$/i).optional().nullable(),
|
||||
allowedHashes: z.array(z.string().trim().regex(/^[a-f0-9]{64}$/i)).max(100).optional(),
|
||||
fieldEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(),
|
||||
fieldNotEquals: z.record(z.string().trim().min(1).max(120), z.unknown()).optional(),
|
||||
fieldIn: z.record(z.string().trim().min(1).max(120), z.array(z.unknown()).min(1).max(100)).optional(),
|
||||
fieldMatches: z.record(z.string().trim().min(1).max(120), z.string().trim().min(1).max(500)).optional(),
|
||||
fieldExists: z.array(z.string().trim().min(1).max(120)).max(100).optional(),
|
||||
fieldAbsent: z.array(z.string().trim().min(1).max(120)).max(100).optional(),
|
||||
}).refine(
|
||||
(value) => value.allowAny === true
|
||||
|| Boolean(value.exactHash)
|
||||
|| Boolean(value.allowedHashes?.length)
|
||||
|| Boolean(value.fieldEquals && Object.keys(value.fieldEquals).length > 0)
|
||||
|| Boolean(value.fieldNotEquals && Object.keys(value.fieldNotEquals).length > 0)
|
||||
|| Boolean(value.fieldIn && Object.keys(value.fieldIn).length > 0)
|
||||
|| Boolean(value.fieldMatches && Object.keys(value.fieldMatches).length > 0)
|
||||
|| Boolean(value.fieldExists?.length)
|
||||
|| Boolean(value.fieldAbsent?.length),
|
||||
{ message: "Trust-rule argument filters must specify allowAny, a hash filter, or a field predicate" },
|
||||
);
|
||||
|
||||
export const toolTrustRuleScopeSchema = z.object({
|
||||
includeAgent: z.boolean().optional(),
|
||||
includeProject: z.boolean().optional(),
|
||||
includeIssue: z.boolean().optional(),
|
||||
includeApplication: z.boolean().optional(),
|
||||
includeConnection: z.boolean().optional(),
|
||||
includeCatalogEntry: z.boolean().optional(),
|
||||
includeTool: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const toolTrustRuleBatchApprovalSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
maxBatchSize: z.number().int().positive().max(100).optional(),
|
||||
windowSeconds: z.number().int().positive().max(31_536_000).optional(),
|
||||
});
|
||||
|
||||
export const createToolTrustRuleFromActionRequestSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
priority: z.number().int().min(0).max(10000).default(40),
|
||||
approvalThreshold: z.number().int().min(1).max(50).default(2),
|
||||
selectors: toolAccessSelectorSchema.optional(),
|
||||
scope: toolTrustRuleScopeSchema.optional(),
|
||||
argumentFilters: toolTrustRuleArgumentFiltersSchema.optional(),
|
||||
expiresAt: z.coerce.date().optional().nullable(),
|
||||
batchApproval: toolTrustRuleBatchApprovalSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateToolTrustRuleFromActionRequest = z.infer<typeof createToolTrustRuleFromActionRequestSchema>;
|
||||
|
||||
export const revokeToolTrustRuleSchema = z.object({
|
||||
reason: z.string().trim().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type RevokeToolTrustRule = z.infer<typeof revokeToolTrustRuleSchema>;
|
||||
|
||||
export const toolPolicyTestRequestSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
actor: z.object({
|
||||
actorType: z.enum(["agent", "user", "system", "plugin"]),
|
||||
actorId: z.string().trim().min(1).max(240),
|
||||
agentId: z.string().uuid().optional().nullable(),
|
||||
}),
|
||||
runContext: z.object({
|
||||
heartbeatRunId: z.string().uuid().optional().nullable(),
|
||||
issueId: z.string().uuid().optional().nullable(),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
routineId: z.string().uuid().optional().nullable(),
|
||||
gatewayId: z.string().uuid().optional().nullable(),
|
||||
gatewayPublicId: z.string().trim().min(1).max(120).regex(safeKeyPattern).optional().nullable(),
|
||||
gatewayTokenId: z.string().uuid().optional().nullable(),
|
||||
clientSubjectType: toolMcpGatewayTokenSubjectTypeSchema.optional().nullable(),
|
||||
clientSubjectId: z.string().trim().min(1).max(240).optional().nullable(),
|
||||
clientName: z.string().trim().min(1).max(160).optional().nullable(),
|
||||
externalClient: z.boolean().optional().nullable(),
|
||||
}).optional().nullable(),
|
||||
request: z.object({
|
||||
applicationId: z.string().uuid().optional().nullable(),
|
||||
connectionId: z.string().uuid().optional().nullable(),
|
||||
catalogEntryId: z.string().uuid().optional().nullable(),
|
||||
toolName: z.string().trim().min(1).max(240),
|
||||
arguments: z.unknown().optional(),
|
||||
idempotencyKey: z.string().trim().min(1).max(512).optional().nullable(),
|
||||
sideEffecting: z.boolean().optional(),
|
||||
}),
|
||||
consumeRateLimit: z.boolean().optional(),
|
||||
writeAuditEvent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ToolPolicyTestRequestInput = z.infer<typeof toolPolicyTestRequestSchema>;
|
||||
|
|
@ -1,29 +1,213 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
createDb,
|
||||
plugins,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
createPluginSecretsHandler,
|
||||
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
|
||||
extractSecretRefBindingsFromConfig,
|
||||
} from "../services/plugin-secrets-handler.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
describe("createPluginSecretsHandler", () => {
|
||||
it("fails closed for plugin secret resolution until company scoping lands", async () => {
|
||||
const handler = createPluginSecretsHandler({
|
||||
db: {} as never,
|
||||
pluginId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
|
||||
|
||||
await expect(
|
||||
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }),
|
||||
).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping plugin secret handler integration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("extractSecretRefBindingsFromConfig", () => {
|
||||
it("ignores UUID strings outside schema-declared secret fields", () => {
|
||||
const externalProjectId = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
expect(extractSecretRefBindingsFromConfig(
|
||||
{ externalProjectId },
|
||||
{ type: "object", properties: { externalProjectId: { type: "string" } } },
|
||||
)).toEqual([]);
|
||||
});
|
||||
|
||||
it("still rejects malformed secret refs before the feature-disable guard", async () => {
|
||||
const handler = createPluginSecretsHandler({
|
||||
db: {} as never,
|
||||
pluginId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
it("rejects legacy UUID strings at schema-declared secret fields", () => {
|
||||
const secretId = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
await expect(
|
||||
handler.resolve({ secretRef: "not-a-uuid" }),
|
||||
).rejects.toThrow(/invalid secret reference/i);
|
||||
expect(() => extractSecretRefBindingsFromConfig(
|
||||
{ token: secretId },
|
||||
{ type: "object", properties: { token: { format: "secret-ref" } } },
|
||||
)).toThrow(/must use.*secret_ref/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPluginSecretsHandler fail-closed guards", () => {
|
||||
it("requires company context before touching the database", async () => {
|
||||
const db = { select: vi.fn(() => { throw new Error("db should not be touched"); }) };
|
||||
const handler = createPluginSecretsHandler({ db: db as never, pluginId });
|
||||
|
||||
await expect(
|
||||
handler.resolve({ secretRef: { type: "secret_ref", secretId: randomUUID() } }),
|
||||
).rejects.toThrow(/companyId is required/i);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects legacy string refs before provider resolution", async () => {
|
||||
const db = { select: vi.fn(() => { throw new Error("db should not be touched"); }) };
|
||||
const handler = createPluginSecretsHandler({ db: db as never, pluginId });
|
||||
|
||||
await expect(
|
||||
handler.resolve({ companyId: randomUUID(), secretRef: randomUUID() }),
|
||||
).rejects.toThrow(/use \{ type: "secret_ref"/i);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("createPluginSecretsHandler shared vault integration", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-plugin-secrets-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("plugin-secrets-handler");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedCompany(name: string) {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `P${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function seedPlugin() {
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.plugin-secrets-test",
|
||||
packageName: "@paperclipai/plugin-secrets-test",
|
||||
version: "0.0.1",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
manifestJson: {
|
||||
id: "paperclip.plugin-secrets-test",
|
||||
apiVersion: 1,
|
||||
version: "0.0.1",
|
||||
displayName: "Plugin Secrets Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
}
|
||||
|
||||
it("resolves bound plugin refs through secretService and emits plugin_worker access events", async () => {
|
||||
await seedPlugin();
|
||||
const companyId = await seedCompany("Plugin Co");
|
||||
const svc = secretService(db);
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `plugin-api-key-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "resolved-plugin-secret",
|
||||
});
|
||||
await svc.syncSecretRefsForTarget(companyId, { targetType: "plugin", targetId: pluginId }, [
|
||||
{ secretId: secret.id, configPath: "apiKey" },
|
||||
], { replaceAll: true });
|
||||
|
||||
const handler = createPluginSecretsHandler({ db, pluginId });
|
||||
await expect(
|
||||
handler.resolve({
|
||||
companyId,
|
||||
secretRef: { type: "secret_ref", secretId: secret.id, version: "latest" },
|
||||
}),
|
||||
).resolves.toBe("resolved-plugin-secret");
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(secretAccessEvents)
|
||||
.where(eq(secretAccessEvents.secretId, secret.id));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
consumerType: "plugin_worker",
|
||||
consumerId: pluginId,
|
||||
configPath: "apiKey",
|
||||
pluginId,
|
||||
outcome: "success",
|
||||
errorCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for cross-company resolve before secret provider access", async () => {
|
||||
await seedPlugin();
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
const svc = secretService(db);
|
||||
const foreignSecret = await svc.create(companyB, {
|
||||
name: `foreign-plugin-secret-${randomUUID()}`,
|
||||
provider: "local_encrypted",
|
||||
value: "foreign-value",
|
||||
});
|
||||
await svc.syncSecretRefsForTarget(companyB, { targetType: "plugin", targetId: pluginId }, [
|
||||
{ secretId: foreignSecret.id, configPath: "apiKey" },
|
||||
], { replaceAll: true });
|
||||
|
||||
const handler = createPluginSecretsHandler({ db, pluginId });
|
||||
await expect(
|
||||
handler.resolve({
|
||||
companyId: companyA,
|
||||
secretRef: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
|
||||
}),
|
||||
).rejects.toThrow(/not bound/i);
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(secretAccessEvents)
|
||||
.where(eq(secretAccessEvents.secretId, foreignSecret.id));
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import crypto from "node:crypto";
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
|
|
@ -277,11 +279,23 @@ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions
|
|||
return;
|
||||
}
|
||||
|
||||
// Step 2b: Check for devUiUrl in plugin config — proxy to local dev server
|
||||
// when a plugin author has configured a dev server URL for hot-reload.
|
||||
const rawCompanyId = req.query.companyId;
|
||||
if (
|
||||
Array.isArray(rawCompanyId) ||
|
||||
(rawCompanyId !== undefined && typeof rawCompanyId !== "string")
|
||||
) {
|
||||
throw badRequest('"companyId" must be a string when provided');
|
||||
}
|
||||
const companyId = typeof rawCompanyId === "string" ? rawCompanyId.trim() : "";
|
||||
if (companyId) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
}
|
||||
|
||||
// Step 2b: Check for devUiUrl in company-scoped plugin config — proxy to
|
||||
// local dev server when a plugin author has configured hot-reload.
|
||||
// See PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||
try {
|
||||
const configRow = await registry.getConfig(plugin.id);
|
||||
const configRow = companyId ? await registry.getConfig(plugin.id, companyId) : null;
|
||||
const devUiUrl =
|
||||
configRow &&
|
||||
typeof configRow === "object" &&
|
||||
|
|
|
|||
|
|
@ -81,8 +81,7 @@ import {
|
|||
setStoredLocalFolder,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
import {
|
||||
extractSecretRefPathsFromConfig,
|
||||
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
|
||||
extractSecretRefBindingsFromConfig,
|
||||
} from "../services/plugin-secrets-handler.js";
|
||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
|
|
@ -2133,6 +2132,11 @@ export function pluginRoutes(
|
|||
router.get("/plugins/:pluginId/config", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId } = req.params;
|
||||
const companyId = typeof req.query.companyId === "string" ? req.query.companyId.trim() : "";
|
||||
if (!companyId) {
|
||||
throw badRequest('"companyId" is required and must be a non-empty string');
|
||||
}
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
|
|
@ -2140,7 +2144,7 @@ export function pluginRoutes(
|
|||
return;
|
||||
}
|
||||
|
||||
const config = await registry.getConfig(plugin.id);
|
||||
const config = await registry.getConfig(plugin.id, companyId);
|
||||
res.json(config);
|
||||
});
|
||||
|
||||
|
|
@ -2170,7 +2174,12 @@ export function pluginRoutes(
|
|||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { configJson?: Record<string, unknown> } | undefined;
|
||||
const body = req.body as { companyId?: string; configJson?: Record<string, unknown> } | undefined;
|
||||
const companyId = typeof body?.companyId === "string" ? body.companyId.trim() : "";
|
||||
if (!companyId) {
|
||||
throw badRequest('"companyId" is required and must be a non-empty string');
|
||||
}
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (!body?.configJson || typeof body.configJson !== "object") {
|
||||
res.status(400).json({ error: '"configJson" is required and must be an object' });
|
||||
return;
|
||||
|
|
@ -2201,13 +2210,14 @@ export function pluginRoutes(
|
|||
}
|
||||
|
||||
try {
|
||||
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
|
||||
if (secretRefsByPath.size > 0) {
|
||||
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
|
||||
const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema);
|
||||
if (secretRefs.length > 0) {
|
||||
res.status(422).json({ error: "Plugin secret references require the governed tool-access server layer" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await registry.upsertConfig(plugin.id, {
|
||||
const result = await registry.upsertConfig(plugin.id, companyId, {
|
||||
companyId,
|
||||
configJson: body.configJson,
|
||||
});
|
||||
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
|
||||
|
|
@ -2225,7 +2235,7 @@ export function pluginRoutes(
|
|||
await bridgeDeps.workerManager.call(
|
||||
plugin.id,
|
||||
"configChanged",
|
||||
{ config: body.configJson },
|
||||
{ config: body.configJson, companyId },
|
||||
);
|
||||
} catch (rpcErr) {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true,
|
||||
enableApps: parsed.data.enableApps ?? false,
|
||||
enablePipelines: parsed.data.enablePipelines ?? false,
|
||||
enableCases: parsed.data.enableCases ?? false,
|
||||
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
|
||||
|
|
@ -213,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
|
||||
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
||||
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
|
||||
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
|
||||
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
|
||||
enableDecisions: parsed.data.enableDecisions ?? false,
|
||||
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
|
||||
|
|
@ -234,6 +236,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableApps: false,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableConferenceRoomChat: false,
|
||||
|
|
@ -242,6 +245,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableExperimentalFileViewer: false,
|
||||
enableCloudSync: false,
|
||||
enableExternalObjects: false,
|
||||
enableSmokeLab: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableDecisions: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
|
|
|
|||
|
|
@ -1062,8 +1062,10 @@ export function buildHostServices(
|
|||
|
||||
return {
|
||||
config: {
|
||||
async get() {
|
||||
const configRow = await registry.getConfig(pluginId);
|
||||
async get(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const configRow = await registry.getConfig(pluginId, companyId);
|
||||
return configRow?.configJson ?? {};
|
||||
},
|
||||
},
|
||||
|
|
@ -1239,7 +1241,9 @@ export function buildHostServices(
|
|||
|
||||
secrets: {
|
||||
async resolve(params) {
|
||||
return secretsHandler.resolve(params);
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return secretsHandler.resolve({ ...params, companyId });
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -2133,18 +2133,11 @@ export function pluginLoader(
|
|||
const hostHandlers = buildHostHandlers(pluginId, manifest);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Retrieve plugin config (if any)
|
||||
// 4. Bootstrap worker config
|
||||
// ------------------------------------------------------------------
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
const configRow = await registry.getConfig(pluginId);
|
||||
if (configRow && typeof configRow === "object" && "configJson" in configRow) {
|
||||
config = (configRow as { configJson: Record<string, unknown> }).configJson ?? {};
|
||||
}
|
||||
} catch {
|
||||
// Config may not exist yet — use empty object
|
||||
log.debug({ pluginId }, "plugin-loader: no config found, using empty config");
|
||||
}
|
||||
// Plugin configuration is company-scoped. Workers receive an empty
|
||||
// bootstrap config and must use ctx.config.get(companyId) at runtime.
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. Spawn worker process
|
||||
|
|
|
|||
|
|
@ -280,27 +280,27 @@ export function pluginRegistryService(db: Db) {
|
|||
|
||||
// ----- Config ---------------------------------------------------------
|
||||
|
||||
/** Retrieve a plugin's instance configuration. */
|
||||
getConfig: (pluginId: string) =>
|
||||
/** Retrieve a plugin's company-scoped configuration. */
|
||||
getConfig: (pluginId: string, companyId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
/**
|
||||
* Create or fully replace a plugin's instance configuration.
|
||||
* If a config row already exists for the plugin it is replaced;
|
||||
* Create or fully replace a plugin's company-scoped configuration.
|
||||
* If a config row already exists for the plugin/company pair it is replaced;
|
||||
* otherwise a new row is inserted.
|
||||
*/
|
||||
upsertConfig: async (pluginId: string, input: UpsertPluginConfig) => {
|
||||
upsertConfig: async (pluginId: string, companyId: string, input: UpsertPluginConfig) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -311,7 +311,7 @@ export function pluginRegistryService(db: Db) {
|
|||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
|
@ -320,6 +320,7 @@ export function pluginRegistryService(db: Db) {
|
|||
.insert(pluginConfig)
|
||||
.values({
|
||||
pluginId,
|
||||
companyId,
|
||||
configJson: input.configJson,
|
||||
})
|
||||
.returning()
|
||||
|
|
@ -327,17 +328,17 @@ export function pluginRegistryService(db: Db) {
|
|||
},
|
||||
|
||||
/**
|
||||
* Partially update a plugin's instance configuration via shallow merge.
|
||||
* Partially update a plugin's company-scoped configuration via shallow merge.
|
||||
* If no config row exists yet one is created with the supplied values.
|
||||
*/
|
||||
patchConfig: async (pluginId: string, input: PatchPluginConfig) => {
|
||||
patchConfig: async (pluginId: string, companyId: string, input: PatchPluginConfig) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
|
|
@ -349,7 +350,7 @@ export function pluginRegistryService(db: Db) {
|
|||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
|
@ -358,6 +359,7 @@ export function pluginRegistryService(db: Db) {
|
|||
.insert(pluginConfig)
|
||||
.values({
|
||||
pluginId,
|
||||
companyId,
|
||||
configJson: input.configJson,
|
||||
})
|
||||
.returning()
|
||||
|
|
@ -368,11 +370,11 @@ export function pluginRegistryService(db: Db) {
|
|||
* Record an error against a plugin's config (e.g. validation failure
|
||||
* against the plugin's instanceConfigSchema).
|
||||
*/
|
||||
setConfigError: async (pluginId: string, lastError: string | null) => {
|
||||
setConfigError: async (pluginId: string, companyId: string, lastError: string | null) => {
|
||||
const rows = await db
|
||||
.update(pluginConfig)
|
||||
.set({ lastError, updatedAt: new Date() })
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||||
.returning();
|
||||
|
||||
if (rows.length === 0) throw notFound("Plugin config not found");
|
||||
|
|
@ -380,10 +382,10 @@ export function pluginRegistryService(db: Db) {
|
|||
},
|
||||
|
||||
/** Delete a plugin's config row. */
|
||||
deleteConfig: async (pluginId: string) => {
|
||||
deleteConfig: async (pluginId: string, companyId: string) => {
|
||||
const rows = await db
|
||||
.delete(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.where(and(eq(pluginConfig.pluginId, pluginId), eq(pluginConfig.companyId, companyId)))
|
||||
.returning();
|
||||
|
||||
return rows[0] ?? null;
|
||||
|
|
|
|||
|
|
@ -1,115 +1,148 @@
|
|||
/**
|
||||
* Plugin secrets host-side handler — resolves secret references through the
|
||||
* Paperclip secret provider system.
|
||||
*
|
||||
* When a plugin worker calls `ctx.secrets.resolve(secretRef)`, the JSON-RPC
|
||||
* request arrives at the host with `{ secretRef }`. This module provides the
|
||||
* concrete `HostServices.secrets` adapter that:
|
||||
*
|
||||
* 1. Parses the `secretRef` string to identify the secret.
|
||||
* 2. Looks up the secret record and its latest version in the database.
|
||||
* 3. Delegates to the configured `SecretProviderModule` to decrypt /
|
||||
* resolve the raw value.
|
||||
* 4. Returns the resolved plaintext value to the worker.
|
||||
*
|
||||
* ## Secret Reference Format
|
||||
*
|
||||
* A `secretRef` is a **secret UUID** — the primary key (`id`) of a row in
|
||||
* the `company_secrets` table. Operators place these UUIDs into plugin
|
||||
* config values; plugin workers resolve them at execution time via
|
||||
* `ctx.secrets.resolve(secretId)`.
|
||||
*
|
||||
* ## Security Invariants
|
||||
*
|
||||
* - Resolved values are **never** logged, persisted, or included in error
|
||||
* messages (per PLUGIN_SPEC.md §22).
|
||||
* - The handler is capability-gated: only plugins with `secrets.read-ref`
|
||||
* declared in their manifest may call it (enforced by `host-client-factory`).
|
||||
* - The host handler itself does not cache resolved values. Each call goes
|
||||
* through the secret provider to honour rotation.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §22 — Secrets
|
||||
* @see host-client-factory.ts — capability gating
|
||||
* @see services/secrets.ts — secretService used by agent env bindings
|
||||
* Plugin secrets host-side handler. Plugin workers may resolve shared
|
||||
* `secret_ref` config bindings only with an explicit company context.
|
||||
*/
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecretBindings } from "@paperclipai/db";
|
||||
import type { EnvSecretRefBinding, SecretProjectionClass, SecretVersionSelector } from "@paperclipai/shared";
|
||||
import { envBindingSecretRefSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
collectSecretRefPaths,
|
||||
isUuidSecretRef,
|
||||
readConfigValueAtPath,
|
||||
} from "./json-schema-secret-refs.js";
|
||||
|
||||
export const PLUGIN_SECRET_REFS_DISABLED_MESSAGE =
|
||||
"Plugin secret references are disabled until company-scoped plugin config lands";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function invalidSecretRef(secretRef: string): Error {
|
||||
const err = new Error(`Invalid secret reference: ${secretRef}`);
|
||||
function invalidSecretRef(secretRef: unknown): Error {
|
||||
const rendered = typeof secretRef === "string" ? secretRef : JSON.stringify(secretRef);
|
||||
const err = new Error(
|
||||
`Invalid secret reference for plugin: ${rendered ?? "<empty>"}. Use { type: "secret_ref", secretId, version? }`,
|
||||
);
|
||||
err.name = "InvalidSecretRefError";
|
||||
return err;
|
||||
}
|
||||
|
||||
function requireCompanyId(companyId: unknown): string {
|
||||
if (typeof companyId !== "string" || companyId.trim().length === 0) {
|
||||
throw unprocessable("companyId is required for plugin secret resolution");
|
||||
}
|
||||
return companyId.trim();
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseSecretRefBinding(value: unknown): EnvSecretRefBinding | null {
|
||||
const parsed = envBindingSecretRefSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function assertSecretRefBinding(
|
||||
value: unknown,
|
||||
path: string,
|
||||
rejectLegacyUuid = false,
|
||||
): EnvSecretRefBinding | null {
|
||||
if (rejectLegacyUuid && typeof value === "string" && isUuidSecretRef(value)) {
|
||||
throw unprocessable(
|
||||
`Plugin secret ref at ${path} must use { type: "secret_ref", secretId, version? }`,
|
||||
);
|
||||
}
|
||||
if (!isPlainRecord(value) || value.type !== "secret_ref") return null;
|
||||
const parsed = parseSecretRefBinding(value);
|
||||
if (!parsed) {
|
||||
throw unprocessable(`Invalid secret_ref binding at ${path}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export interface PluginConfigSecretRefBinding {
|
||||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
label?: string | null;
|
||||
projectionClass?: SecretProjectionClass;
|
||||
projectionAllowlistKey?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract secret reference UUIDs from a plugin's configJson, scoped to only
|
||||
* the fields annotated with `format: "secret-ref"` in the schema.
|
||||
*
|
||||
* When no schema is provided, falls back to collecting all UUID-shaped strings
|
||||
* (backwards-compatible for plugins without a declared instanceConfigSchema).
|
||||
*/
|
||||
/** Extract shared object-shaped secret refs from plugin config. */
|
||||
export function extractSecretRefBindingsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): PluginConfigSecretRefBinding[] {
|
||||
if (configJson == null || typeof configJson !== "object") return [];
|
||||
|
||||
const refsByPath = new Map<string, PluginConfigSecretRefBinding>();
|
||||
const addRef = (binding: EnvSecretRefBinding, configPath: string) => {
|
||||
refsByPath.set(configPath, {
|
||||
secretId: binding.secretId,
|
||||
configPath,
|
||||
versionSelector: binding.version ?? "latest",
|
||||
required: true,
|
||||
label: configPath,
|
||||
projectionClass: binding.projectionClass,
|
||||
projectionAllowlistKey: binding.projectionAllowlistKey ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const secretPaths = collectSecretRefPaths(schema);
|
||||
for (const dotPath of secretPaths) {
|
||||
const current = readConfigValueAtPath(configJson as Record<string, unknown>, dotPath);
|
||||
const binding = assertSecretRefBinding(current, dotPath, true);
|
||||
if (binding) addRef(binding, dotPath);
|
||||
}
|
||||
|
||||
function walk(value: unknown, path: string): void {
|
||||
const binding = assertSecretRefBinding(value, path || "$");
|
||||
if (binding) {
|
||||
addRef(binding, path || "$");
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => walk(item, path ? `${path}.${index}` : String(index)));
|
||||
return;
|
||||
}
|
||||
if (!isPlainRecord(value)) return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
walk(child, path ? `${path}.${key}` : key);
|
||||
}
|
||||
}
|
||||
|
||||
walk(configJson, "");
|
||||
return [...refsByPath.values()];
|
||||
}
|
||||
|
||||
/** Backward-compatible helper returning only secret IDs. */
|
||||
export function extractSecretRefsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): Set<string> {
|
||||
return new Set(extractSecretRefPathsFromConfig(configJson, schema).keys());
|
||||
return new Set(extractSecretRefBindingsFromConfig(configJson, schema).map((ref) => ref.secretId));
|
||||
}
|
||||
|
||||
/** Backward-compatible helper returning secret IDs grouped by config path. */
|
||||
export function extractSecretRefPathsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): Map<string, Set<string>> {
|
||||
const refs = new Map<string, Set<string>>();
|
||||
const addRef = (secretRef: string, path: string) => {
|
||||
const existing = refs.get(secretRef) ?? new Set<string>();
|
||||
existing.add(path);
|
||||
refs.set(secretRef, existing);
|
||||
};
|
||||
if (configJson == null || typeof configJson !== "object") return new Map();
|
||||
|
||||
const secretPaths = collectSecretRefPaths(schema);
|
||||
|
||||
// If schema declares secret-ref paths, extract only those values.
|
||||
if (secretPaths.size > 0) {
|
||||
for (const dotPath of secretPaths) {
|
||||
const current = readConfigValueAtPath(configJson as Record<string, unknown>, dotPath);
|
||||
if (typeof current === "string" && isUuidSecretRef(current)) {
|
||||
addRef(current, dotPath);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
for (const ref of extractSecretRefBindingsFromConfig(configJson, schema)) {
|
||||
const paths = refs.get(ref.secretId) ?? new Set<string>();
|
||||
paths.add(ref.configPath);
|
||||
refs.set(ref.secretId, paths);
|
||||
}
|
||||
|
||||
// Fallback: no schema or no secret-ref annotations — collect all UUIDs.
|
||||
// This preserves backwards compatibility for plugins that omit
|
||||
// instanceConfigSchema.
|
||||
function walkAll(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
if (isUuidSecretRef(value)) addRef(value, "$");
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) walkAll(item);
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
for (const v of Object.values(value as Record<string, unknown>)) walkAll(v);
|
||||
}
|
||||
}
|
||||
|
||||
walkAll(configJson);
|
||||
return refs;
|
||||
}
|
||||
|
||||
|
|
@ -117,69 +150,28 @@ export function extractSecretRefPathsFromConfig(
|
|||
// Handler factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Input shape for the `secrets.resolve` handler.
|
||||
*
|
||||
* Matches `WorkerToHostMethods["secrets.resolve"][0]` from `protocol.ts`.
|
||||
*/
|
||||
export interface PluginSecretsResolveParams {
|
||||
/** The secret reference string (a secret UUID). */
|
||||
secretRef: string;
|
||||
/** Shared secret reference object from company-scoped plugin config. */
|
||||
secretRef: string | EnvSecretRefBinding;
|
||||
/** Authorized company context for this worker invocation. */
|
||||
companyId?: string;
|
||||
/** Config path that produced this ref. Required when a secret appears in multiple paths. */
|
||||
configPath?: string;
|
||||
actorType?: "agent" | "user" | "system" | "plugin";
|
||||
actorId?: string | null;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating the plugin secrets handler.
|
||||
*/
|
||||
export interface PluginSecretsHandlerOptions {
|
||||
/** Database connection. */
|
||||
db: Db;
|
||||
/**
|
||||
* The plugin ID using this handler.
|
||||
* Used for logging context only; never included in error payloads
|
||||
* that reach the plugin worker.
|
||||
*/
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `HostServices.secrets` adapter for the plugin host-client factory.
|
||||
*/
|
||||
export interface PluginSecretsService {
|
||||
/**
|
||||
* Resolve a secret reference to its current plaintext value.
|
||||
*
|
||||
* @param params - Contains the `secretRef` (UUID of the secret)
|
||||
* @returns The resolved secret value
|
||||
* @throws {Error} If the secret is not found, has no versions, or
|
||||
* the provider fails to resolve
|
||||
*/
|
||||
resolve(params: PluginSecretsResolveParams): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `HostServices.secrets` adapter for a specific plugin.
|
||||
*
|
||||
* The returned service looks up secrets by UUID, fetches the latest version
|
||||
* material, and delegates to the appropriate `SecretProviderModule` for
|
||||
* decryption.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
||||
* const handlers = createHostClientHandlers({
|
||||
* pluginId,
|
||||
* capabilities: manifest.capabilities,
|
||||
* services: {
|
||||
* secrets: secretsHandler,
|
||||
* // ...
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param options - Database connection and plugin identity
|
||||
* @returns A `PluginSecretsService` suitable for `HostServices.secrets`
|
||||
*/
|
||||
/** Simple sliding-window rate limiter for secret resolution attempts. */
|
||||
function createRateLimiter(maxAttempts: number, windowMs: number) {
|
||||
const attempts = new Map<string, number[]>();
|
||||
|
||||
|
|
@ -199,40 +191,95 @@ function createRateLimiter(maxAttempts: number, windowMs: number) {
|
|||
export function createPluginSecretsHandler(
|
||||
options: PluginSecretsHandlerOptions,
|
||||
): PluginSecretsService {
|
||||
const { pluginId } = options;
|
||||
|
||||
// Rate limit: max 30 resolution attempts per plugin per minute
|
||||
const { db, pluginId } = options;
|
||||
const rateLimiter = createRateLimiter(30, 60_000);
|
||||
|
||||
async function lookupBinding(input: {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
versionSelector: SecretVersionSelector;
|
||||
configPath?: string;
|
||||
}) {
|
||||
const conditions = [
|
||||
eq(companySecretBindings.companyId, input.companyId),
|
||||
eq(companySecretBindings.targetType, "plugin"),
|
||||
eq(companySecretBindings.targetId, pluginId),
|
||||
eq(companySecretBindings.secretId, input.secretId),
|
||||
];
|
||||
if (input.configPath) {
|
||||
conditions.push(eq(companySecretBindings.configPath, input.configPath));
|
||||
}
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
.where(and(...conditions));
|
||||
const matchingVersion = rows.filter(
|
||||
(row) => row.versionSelector === String(input.versionSelector),
|
||||
);
|
||||
return matchingVersion;
|
||||
}
|
||||
|
||||
return {
|
||||
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
||||
const { secretRef } = params;
|
||||
if (typeof params.secretRef === "string") {
|
||||
throw invalidSecretRef(params.secretRef.trim() || "<empty>");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 0. Rate limiting — prevent brute-force UUID enumeration
|
||||
// ---------------------------------------------------------------
|
||||
if (!rateLimiter.check(pluginId)) {
|
||||
const bindingRef = parseSecretRefBinding(params.secretRef);
|
||||
if (!bindingRef) throw invalidSecretRef(params.secretRef);
|
||||
|
||||
const companyId = requireCompanyId(params.companyId);
|
||||
|
||||
if (!rateLimiter.check(`${companyId}:${pluginId}`)) {
|
||||
const err = new Error("Rate limit exceeded for secret resolution");
|
||||
err.name = "RateLimitExceededError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 1. Validate the ref format
|
||||
// ---------------------------------------------------------------
|
||||
if (!secretRef || typeof secretRef !== "string" || secretRef.trim().length === 0) {
|
||||
throw invalidSecretRef(secretRef ?? "<empty>");
|
||||
const versionSelector = bindingRef.version ?? "latest";
|
||||
const bindings = await lookupBinding({
|
||||
companyId,
|
||||
secretId: bindingRef.secretId,
|
||||
versionSelector,
|
||||
configPath: params.configPath,
|
||||
});
|
||||
|
||||
if (bindings.length === 0) {
|
||||
throw unprocessable(
|
||||
`Secret is not bound to plugin:${pluginId}${params.configPath ? ` at ${params.configPath}` : ""}`,
|
||||
{ code: "binding_missing" },
|
||||
);
|
||||
}
|
||||
if (bindings.length > 1) {
|
||||
throw unprocessable(
|
||||
"Plugin secret reference is ambiguous; pass configPath when resolving this secret",
|
||||
{ code: "binding_ambiguous" },
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedRef = secretRef.trim();
|
||||
|
||||
if (!isUuidSecretRef(trimmedRef)) {
|
||||
throw invalidSecretRef(trimmedRef);
|
||||
}
|
||||
|
||||
// Fail closed until plugin config and worker runtime both carry an
|
||||
// explicit company scope for secret bindings and resolution.
|
||||
throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
|
||||
const binding = bindings[0]!;
|
||||
return secretService(db).resolveSecretValue(companyId, bindingRef.secretId, versionSelector, {
|
||||
bindingContext: {
|
||||
consumerType: "plugin",
|
||||
consumerId: pluginId,
|
||||
configPath: binding.configPath,
|
||||
actorType: params.actorType ?? "plugin",
|
||||
actorId: params.actorId ?? pluginId,
|
||||
issueId: params.issueId ?? null,
|
||||
heartbeatRunId: params.heartbeatRunId ?? null,
|
||||
pluginId,
|
||||
},
|
||||
accessContext: {
|
||||
consumerType: "plugin_worker",
|
||||
consumerId: pluginId,
|
||||
configPath: binding.configPath,
|
||||
actorType: params.actorType ?? "plugin",
|
||||
actorId: params.actorId ?? pluginId,
|
||||
issueId: params.issueId ?? null,
|
||||
heartbeatRunId: params.heartbeatRunId ?? null,
|
||||
pluginId,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type {
|
|||
RemoteSecretImportRowResult,
|
||||
SecretProviderConfigDiscoveryPreviewResult,
|
||||
SecretBindingTargetType,
|
||||
SecretProjectionClass,
|
||||
SecretProvider,
|
||||
SecretProviderConfigHealthResponse,
|
||||
SecretProviderConfigHealthStatus,
|
||||
|
|
@ -33,6 +34,7 @@ import type {
|
|||
SecretVersionSelector,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
CLASS3_STATIC_LEASE_ALLOWLIST,
|
||||
createSecretProviderConfigSchema,
|
||||
deriveProjectUrlKey,
|
||||
envBindingSchema,
|
||||
|
|
@ -377,7 +379,13 @@ async function cleanupPreparedProviderWrite(input: {
|
|||
|
||||
type CanonicalEnvBinding =
|
||||
| { type: "plain"; value: string }
|
||||
| { type: "secret_ref"; secretId: string; version: number | "latest" }
|
||||
| {
|
||||
type: "secret_ref";
|
||||
secretId: string;
|
||||
version: number | "latest";
|
||||
projectionClass: SecretProjectionClass;
|
||||
projectionAllowlistKey: string | null;
|
||||
}
|
||||
| {
|
||||
type: "user_secret_ref";
|
||||
key: string;
|
||||
|
|
@ -386,8 +394,10 @@ type CanonicalEnvBinding =
|
|||
allowMissingOverride: boolean;
|
||||
};
|
||||
|
||||
type SecretAccessConsumerType = SecretBindingTargetType | "plugin_worker";
|
||||
|
||||
type SecretConsumerContext = {
|
||||
consumerType: SecretBindingTargetType;
|
||||
consumerType: SecretAccessConsumerType;
|
||||
consumerId: string;
|
||||
configPath?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
|
|
@ -400,8 +410,12 @@ type SecretConsumerContext = {
|
|||
allowedBindingIds?: string[] | null;
|
||||
};
|
||||
|
||||
type SecretBindingContext = Omit<SecretConsumerContext, "consumerType"> & {
|
||||
consumerType: SecretBindingTargetType;
|
||||
};
|
||||
|
||||
type SecretResolutionOptions = {
|
||||
bindingContext?: SecretConsumerContext;
|
||||
bindingContext?: SecretBindingContext;
|
||||
accessContext?: SecretConsumerContext;
|
||||
allowUserSecretScope?: boolean;
|
||||
};
|
||||
|
|
@ -439,6 +453,10 @@ export type MissingRuntimeBinding = {
|
|||
errorCode?: SecretResolutionErrorCode;
|
||||
};
|
||||
|
||||
function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType {
|
||||
return consumerType === "plugin_worker" ? "plugin" : consumerType;
|
||||
}
|
||||
|
||||
type RuntimeSecretResolution = {
|
||||
value: string;
|
||||
manifestEntry: RuntimeSecretManifestEntry;
|
||||
|
|
@ -502,9 +520,41 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
|||
type: "secret_ref",
|
||||
secretId: binding.secretId,
|
||||
version: binding.version ?? "latest",
|
||||
projectionClass: binding.projectionClass ?? "unclassified",
|
||||
projectionAllowlistKey: binding.projectionAllowlistKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertClass3StaticLeaseAllowed(input: {
|
||||
targetType: SecretBindingTargetType;
|
||||
configPath: string;
|
||||
projectionClass?: string | null;
|
||||
projectionAllowlistKey?: string | null;
|
||||
}) {
|
||||
const projectionClass = input.projectionClass ?? "unclassified";
|
||||
if (projectionClass !== "class_3_static_lease") return;
|
||||
if (!input.projectionAllowlistKey?.trim()) {
|
||||
throw unprocessable("Class-3 static lease bindings require an allowlist key", {
|
||||
code: "class_3_static_lease_allowlist_required",
|
||||
targetType: input.targetType,
|
||||
configPath: input.configPath,
|
||||
});
|
||||
}
|
||||
const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some((entry) =>
|
||||
entry.key === input.projectionAllowlistKey
|
||||
&& entry.targetType === input.targetType
|
||||
&& entry.configPath === input.configPath
|
||||
);
|
||||
if (!allowed) {
|
||||
throw unprocessable("Class-3 static lease binding is outside the approved allowlist", {
|
||||
code: "class_3_static_lease_not_allowed",
|
||||
allowlistKey: input.projectionAllowlistKey,
|
||||
targetType: input.targetType,
|
||||
configPath: input.configPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus {
|
||||
return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready";
|
||||
}
|
||||
|
|
@ -556,7 +606,7 @@ function missingUserSecretDefinitionRuntimeBinding(
|
|||
errorCode: "user_secret_definition_missing" | "user_secret_definition_inactive",
|
||||
): MissingRuntimeBinding {
|
||||
return {
|
||||
consumerType: context.consumerType,
|
||||
consumerType: missingRuntimeConsumerType(context.consumerType),
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
|
|
@ -737,7 +787,7 @@ export function secretService(db: Db) {
|
|||
async function assertBindingContext(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
context: SecretConsumerContext | undefined,
|
||||
context: SecretBindingContext | undefined,
|
||||
) {
|
||||
if (!context) return null;
|
||||
if (!context.configPath) {
|
||||
|
|
@ -765,6 +815,12 @@ export function secretService(db: Db) {
|
|||
{ code: "binding_not_allowed" },
|
||||
);
|
||||
}
|
||||
assertClass3StaticLeaseAllowed({
|
||||
targetType: binding.targetType as SecretBindingTargetType,
|
||||
configPath: binding.configPath,
|
||||
projectionClass: binding.projectionClass,
|
||||
projectionAllowlistKey: binding.projectionAllowlistKey,
|
||||
});
|
||||
return binding;
|
||||
}
|
||||
|
||||
|
|
@ -1053,16 +1109,22 @@ export function secretService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
function isSecretResolutionOptions(
|
||||
value: SecretBindingContext | SecretResolutionOptions | undefined,
|
||||
): value is SecretResolutionOptions {
|
||||
return Boolean(value && ("bindingContext" in value || "accessContext" in value));
|
||||
}
|
||||
|
||||
async function resolveSecretValue(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
context?: SecretConsumerContext,
|
||||
contextOrOptions?: SecretBindingContext | SecretResolutionOptions,
|
||||
): Promise<string> {
|
||||
return (await resolveSecretValueInternal(companyId, secretId, version, {
|
||||
bindingContext: context,
|
||||
accessContext: context,
|
||||
})).value;
|
||||
const options = isSecretResolutionOptions(contextOrOptions)
|
||||
? contextOrOptions
|
||||
: { bindingContext: contextOrOptions, accessContext: contextOrOptions };
|
||||
return (await resolveSecretValueInternal(companyId, secretId, version, options)).value;
|
||||
}
|
||||
|
||||
async function resolveSecretValueForEphemeralAccess(
|
||||
|
|
@ -1112,6 +1174,31 @@ export function secretService(db: Db) {
|
|||
})).value;
|
||||
}
|
||||
|
||||
async function resolveSecretVersion(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
context?: SecretBindingContext,
|
||||
): Promise<number> {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
|
||||
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
|
||||
if (secret.status === "deleted") {
|
||||
throw new HttpError(404, "Secret not found", { code: "secret_deleted" });
|
||||
}
|
||||
if (secret.status !== "active") {
|
||||
throw unprocessable("Secret is not active", { code: "secret_inactive" });
|
||||
}
|
||||
await assertBindingContext(companyId, secret.id, context);
|
||||
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
|
||||
if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" });
|
||||
if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) {
|
||||
throw unprocessable("Secret version is not active", { code: "version_inactive" });
|
||||
}
|
||||
return resolvedVersion;
|
||||
}
|
||||
|
||||
async function normalizeEnvConfig(
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
|
|
@ -1154,6 +1241,8 @@ export function secretService(db: Db) {
|
|||
type: "secret_ref",
|
||||
secretId: binding.secretId,
|
||||
version: binding.version,
|
||||
projectionClass: binding.projectionClass,
|
||||
projectionAllowlistKey: binding.projectionAllowlistKey,
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
|
|
@ -1226,6 +1315,8 @@ export function secretService(db: Db) {
|
|||
type: "secret_ref",
|
||||
secretId: binding.secretId,
|
||||
version: binding.version,
|
||||
projectionClass: binding.projectionClass,
|
||||
projectionAllowlistKey: binding.projectionAllowlistKey,
|
||||
};
|
||||
}
|
||||
if (binding.type === "user_secret_ref") {
|
||||
|
|
@ -2928,6 +3019,7 @@ export function secretService(db: Db) {
|
|||
getById,
|
||||
getByName,
|
||||
resolveSecretValue,
|
||||
resolveSecretVersion,
|
||||
resolveSecretValueForEphemeralAccess,
|
||||
|
||||
create: async (
|
||||
|
|
@ -3402,8 +3494,16 @@ export function secretService(db: Db) {
|
|||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
label?: string | null;
|
||||
projectionClass?: SecretProjectionClass;
|
||||
projectionAllowlistKey?: string | null;
|
||||
}) => {
|
||||
await assertSecretInCompany(input.companyId, input.secretId);
|
||||
assertClass3StaticLeaseAllowed({
|
||||
targetType: input.targetType,
|
||||
configPath: input.configPath,
|
||||
projectionClass: input.projectionClass,
|
||||
projectionAllowlistKey: input.projectionAllowlistKey,
|
||||
});
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(companySecretBindings)
|
||||
|
|
@ -3428,6 +3528,8 @@ export function secretService(db: Db) {
|
|||
versionSelector: String(input.versionSelector ?? "latest"),
|
||||
required: input.required ?? true,
|
||||
label: input.label ?? null,
|
||||
projectionClass: input.projectionClass ?? "unclassified",
|
||||
projectionAllowlistKey: input.projectionAllowlistKey ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
|
@ -3442,6 +3544,8 @@ export function secretService(db: Db) {
|
|||
versionSelector?: SecretVersionSelector;
|
||||
required?: boolean;
|
||||
label?: string | null;
|
||||
projectionClass?: SecretProjectionClass;
|
||||
projectionAllowlistKey?: string | null;
|
||||
}>,
|
||||
options?: { replaceAll?: boolean },
|
||||
) => {
|
||||
|
|
@ -3451,15 +3555,27 @@ export function secretService(db: Db) {
|
|||
versionSelector: SecretVersionSelector;
|
||||
required: boolean;
|
||||
label: string | null;
|
||||
projectionClass: SecretProjectionClass;
|
||||
projectionAllowlistKey: string | null;
|
||||
}> = [];
|
||||
for (const ref of refs) {
|
||||
await assertSecretInCompany(companyId, ref.secretId);
|
||||
const projectionClass = ref.projectionClass ?? "unclassified";
|
||||
const projectionAllowlistKey = ref.projectionAllowlistKey ?? null;
|
||||
assertClass3StaticLeaseAllowed({
|
||||
targetType: target.targetType,
|
||||
configPath: ref.configPath,
|
||||
projectionClass,
|
||||
projectionAllowlistKey,
|
||||
});
|
||||
normalizedRefs.push({
|
||||
secretId: ref.secretId,
|
||||
configPath: ref.configPath,
|
||||
versionSelector: ref.versionSelector ?? "latest",
|
||||
required: ref.required ?? true,
|
||||
label: ref.label ?? null,
|
||||
projectionClass,
|
||||
projectionAllowlistKey,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3514,6 +3630,8 @@ export function secretService(db: Db) {
|
|||
versionSelector: String(ref.versionSelector),
|
||||
required: ref.required,
|
||||
label: ref.label,
|
||||
projectionClass: ref.projectionClass,
|
||||
projectionAllowlistKey: ref.projectionAllowlistKey,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
|
@ -3545,6 +3663,8 @@ export function secretService(db: Db) {
|
|||
secretId: string;
|
||||
configPath: string;
|
||||
versionSelector: SecretVersionSelector;
|
||||
projectionClass: SecretProjectionClass;
|
||||
projectionAllowlistKey: string | null;
|
||||
}> = [];
|
||||
const userRefs: Array<{
|
||||
definitionKey: string;
|
||||
|
|
@ -3574,10 +3694,19 @@ export function secretService(db: Db) {
|
|||
}
|
||||
if (binding.type !== "secret_ref") continue;
|
||||
await assertSecretInCompany(companyId, binding.secretId, bindingDb);
|
||||
const configPath = `${pathPrefix}.${key}`;
|
||||
assertClass3StaticLeaseAllowed({
|
||||
targetType: target.targetType,
|
||||
configPath,
|
||||
projectionClass: binding.projectionClass,
|
||||
projectionAllowlistKey: binding.projectionAllowlistKey,
|
||||
});
|
||||
refs.push({
|
||||
secretId: binding.secretId,
|
||||
configPath: `${pathPrefix}.${key}`,
|
||||
configPath,
|
||||
versionSelector: binding.version,
|
||||
projectionClass: binding.projectionClass,
|
||||
projectionAllowlistKey: binding.projectionAllowlistKey,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3602,6 +3731,8 @@ export function secretService(db: Db) {
|
|||
configPath: ref.configPath,
|
||||
versionSelector: String(ref.versionSelector),
|
||||
required: true,
|
||||
projectionClass: ref.projectionClass,
|
||||
projectionAllowlistKey: ref.projectionAllowlistKey,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
|
@ -3684,7 +3815,7 @@ export function secretService(db: Db) {
|
|||
resolveEnvBindings: async (
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
context?: Omit<SecretBindingContext, "configPath">,
|
||||
): Promise<{ env: Record<string, string>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>(), manifest: [] };
|
||||
|
|
@ -3752,7 +3883,7 @@ export function secretService(db: Db) {
|
|||
collectMissingRuntimeBindings: async (
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
context: Omit<SecretConsumerContext, "configPath">,
|
||||
context: Omit<SecretBindingContext, "configPath">,
|
||||
): Promise<MissingRuntimeBinding[]> => {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) return [];
|
||||
|
|
@ -3912,7 +4043,7 @@ export function secretService(db: Db) {
|
|||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
adapterType: string | null | undefined,
|
||||
context: Omit<SecretConsumerContext, "configPath">,
|
||||
context: Omit<SecretBindingContext, "configPath">,
|
||||
): Promise<MissingRuntimeBinding[]> => {
|
||||
const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType);
|
||||
const secretRefs = secretFieldKeys.flatMap((key) => {
|
||||
|
|
@ -4068,7 +4199,7 @@ export function secretService(db: Db) {
|
|||
resolveAdapterConfigForRuntime: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
context?: Omit<SecretBindingContext, "configPath">,
|
||||
opts?: ResolveAdapterConfigForRuntimeOptions,
|
||||
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const SERVER_INFO_TOGGLE_SELECTOR =
|
|||
'button[aria-label="Toggle server info debug view experimental setting"]';
|
||||
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle built-in agents experimental setting"]';
|
||||
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
|
||||
const AUTO_RECOVERY_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle task graph liveness auto-recovery"]';
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
enableApps: false,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableConferenceRoomChat: false,
|
||||
|
|
@ -75,6 +77,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableTaskWatchdogs: false,
|
||||
enableCloudSync: false,
|
||||
enableServerInfoDebugView: false,
|
||||
enableSmokeLab: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
|
|
@ -185,6 +188,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
|||
expect(warning?.textContent).toContain("no compatibility guarantees");
|
||||
});
|
||||
|
||||
it("enables the Apps UI from experimental settings", async () => {
|
||||
await renderPage();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(APPS_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(() => toggle?.click());
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableApps: true });
|
||||
expect(container.querySelector(APPS_TOGGLE_SELECTOR)?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not render the Conference Room Chat experimental setting for now", async () => {
|
||||
await renderPage();
|
||||
|
||||
|
|
|
|||
|
|
@ -1563,6 +1563,8 @@ export const storybookSecretBindings: CompanySecretBinding[] = [
|
|||
versionSelector: "latest",
|
||||
required: true,
|
||||
label: "Codex agent env",
|
||||
projectionClass: "unclassified",
|
||||
projectionAllowlistKey: null,
|
||||
createdAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||
},
|
||||
|
|
@ -1576,6 +1578,8 @@ export const storybookSecretBindings: CompanySecretBinding[] = [
|
|||
versionSelector: "latest",
|
||||
required: true,
|
||||
label: "Paperclip App project env",
|
||||
projectionClass: "unclassified",
|
||||
projectionAllowlistKey: null,
|
||||
createdAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-02T09:00:00.000Z"),
|
||||
},
|
||||
|
|
@ -1589,6 +1593,8 @@ export const storybookSecretBindings: CompanySecretBinding[] = [
|
|||
versionSelector: 2,
|
||||
required: true,
|
||||
label: "Prod environment",
|
||||
projectionClass: "unclassified",
|
||||
projectionAllowlistKey: null,
|
||||
createdAt: new Date("2026-04-22T14:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-22T14:01:00.000Z"),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue