mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connection intents need secure provider implementations to complete setup. > - Some providers use managed OAuth or external credential brokers. > - Those tokens must stay out of durable Paperclip state and fail closed when refresh fails. > - This pull request adds managed connector backends and the required storage contract. > - The benefit is safer provider setup with governed credential lifecycles. ## Linked Issues or Issue Description Refs #11965 This is stack 8 of 11. It depends on stack 7 and replaces another reviewable part of #11965. ## What Changed - Add managed Google Workspace and external connector backends. - Add Vercel Connect support without storing provider bearer tokens. - Add replay-safe migration 0232 and its generated snapshot. - Fail closed and clear stale token bindings when organization OAuth refresh needs reauthorization. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` - Result: 194 tests passed. - `pnpm --filter @paperclipai/db check:migrations` - `pnpm build` - `pnpm exec vitest run --project @paperclipai/server server/src/services/remote-url-credentials.test.ts` (5 passed, including URL userinfo vault extraction) ## Risks - Broker metadata errors can block provider setup. - OAuth refresh failure disables the shared organization connection until reauthorization. - Migration 0232 is generated, ordered after 0231, and safe to replay. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## 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 linked the public source pull request with `Refs #` - [x] I have not referenced internal or instance-local Paperclip issues or links - [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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
80 lines
3.9 KiB
TypeScript
80 lines
3.9 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import postgres from "postgres";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { applyPendingMigrations } from "./client.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
|
|
|
const MIGRATION_FILE = "0233_living_dreaming_celestial.sql";
|
|
const migrationSql = fs.readFileSync(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
|
|
const migrationHash = createHash("sha256").update(migrationSql).digest("hex");
|
|
const cleanups: Array<() => Promise<void>> = [];
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
afterEach(async () => {
|
|
while (cleanups.length > 0) await cleanups.pop()?.();
|
|
});
|
|
|
|
describeEmbeddedPostgres("Vercel Connect credential-source migration", () => {
|
|
it("defaults legacy rows to the vault and rejects mixed credential custody", async () => {
|
|
const database = await startEmbeddedPostgresTestDatabase("paperclip-vercel-connect-migration-");
|
|
cleanups.push(database.cleanup);
|
|
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
|
cleanups.push(async () => sql.end());
|
|
|
|
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${migrationHash}`;
|
|
await sql`ALTER TABLE "connection_grants" DROP COLUMN "external_credential"`;
|
|
await sql`ALTER TABLE "tool_connections" DROP COLUMN "external_credential"`;
|
|
await sql`ALTER TABLE "tool_connections" DROP COLUMN "credential_source"`;
|
|
|
|
const companyId = randomUUID();
|
|
const applicationId = randomUUID();
|
|
const connectionId = randomUUID();
|
|
await sql`INSERT INTO "companies" ("id", "name", "issue_prefix") VALUES (${companyId}, 'Vercel migration', 'VCM')`;
|
|
await sql`INSERT INTO "tool_applications" ("id", "company_id", "name", "type") VALUES (${applicationId}, ${companyId}, 'PostHog', 'mcp_http')`;
|
|
await sql`
|
|
INSERT INTO "tool_connections" ("id", "company_id", "application_id", "name", "uid", "transport")
|
|
VALUES (${connectionId}, ${companyId}, ${applicationId}, 'PostHog', ${`posthog/${connectionId}`}, 'mcp_remote')
|
|
`;
|
|
|
|
await applyPendingMigrations(database.connectionString);
|
|
|
|
const [legacy] = await sql<{ credential_source: string; external_credential: unknown }[]>`
|
|
SELECT "credential_source", "external_credential" FROM "tool_connections" WHERE "id" = ${connectionId}
|
|
`;
|
|
expect(legacy).toEqual({ credential_source: "paperclip_vault", external_credential: null });
|
|
|
|
await expect(sql`
|
|
UPDATE "tool_connections" SET "credential_source" = 'vercel_connect' WHERE "id" = ${connectionId}
|
|
`).rejects.toMatchObject({ code: "23514" });
|
|
|
|
await sql`
|
|
UPDATE "tool_connections" SET
|
|
"credential_source" = 'vercel_connect',
|
|
"external_credential" = ${sql.json({ provider: "vercel_connect", connectorUid: "posthog-paperclip" })},
|
|
"credential_refs" = '[]'::jsonb,
|
|
"credential_secret_refs" = '[]'::jsonb
|
|
WHERE "id" = ${connectionId}
|
|
`;
|
|
await expect(sql`
|
|
UPDATE "tool_connections" SET "credential_secret_refs" = ${sql.json([{ secretId: randomUUID() }])}
|
|
WHERE "id" = ${connectionId}
|
|
`).rejects.toMatchObject({ code: "23514" });
|
|
|
|
const grantId = randomUUID();
|
|
await sql`
|
|
INSERT INTO "connection_grants" (
|
|
"id", "company_id", "connection_id", "kind", "is_default", "external_credential"
|
|
) VALUES (
|
|
${grantId}, ${companyId}, ${connectionId}, 'organization', true,
|
|
${sql.json({ provider: "vercel_connect", subjectType: "app" })}
|
|
)
|
|
`;
|
|
await expect(sql`
|
|
UPDATE "connection_grants" SET "credential_secret_refs" = ${sql.json([{ secretId: randomUUID() }])}
|
|
WHERE "id" = ${grantId}
|
|
`).rejects.toMatchObject({ code: "23514" });
|
|
}, 30_000);
|
|
});
|