mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +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
138 lines
5.1 KiB
TypeScript
138 lines
5.1 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
deriveVercelConnectSubject,
|
|
vercelConnectCallbackUrl,
|
|
vercelConnectIntegrationStatus,
|
|
vercelConnectSdkOptions,
|
|
vercelGrantReference,
|
|
vercelTokenRequest,
|
|
} from "./vercel-connect.js";
|
|
import type { VercelConnectCredentialReference } from "@paperclipai/shared";
|
|
|
|
const credential: VercelConnectCredentialReference = {
|
|
provider: "vercel_connect",
|
|
connectorId: "scl_posthog",
|
|
connectorUid: "posthog-paperclip",
|
|
service: "posthog",
|
|
connectorType: "api-key",
|
|
principalMode: "user",
|
|
headerName: "Authorization",
|
|
headerPrefix: "Bearer ",
|
|
scopes: ["read", "write"],
|
|
};
|
|
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
describe("Vercel Connect credential adapter", () => {
|
|
it("canonicalizes Paperclip's loopback callback to Vercel's accepted localhost form", () => {
|
|
expect(vercelConnectCallbackUrl(
|
|
"http://127.0.0.1:3200/api/tools/oauth/callback?stale=1#fragment",
|
|
"one-time-state",
|
|
)).toBe("http://localhost:3200/api/tools/vercel-connect/callback?state=one-time-state");
|
|
expect(vercelConnectCallbackUrl(
|
|
"https://paperclip.example/api/tools/oauth/callback",
|
|
"one-time-state",
|
|
)).toBe("https://paperclip.example/api/tools/vercel-connect/callback?state=one-time-state");
|
|
});
|
|
|
|
it("gates new setup separately from runtime credential availability", () => {
|
|
expect(vercelConnectIntegrationStatus({
|
|
PAPERCLIP_VERCEL_CONNECT_ENABLED: "false",
|
|
PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN: "bootstrap-token",
|
|
} as NodeJS.ProcessEnv)).toMatchObject({
|
|
enabled: false,
|
|
configured: true,
|
|
authentication: "access_token",
|
|
});
|
|
expect(vercelConnectIntegrationStatus({
|
|
PAPERCLIP_VERCEL_CONNECT_ENABLED: "true",
|
|
VERCEL_OIDC_TOKEN: "workload-token",
|
|
PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN: "fallback-token",
|
|
} as NodeJS.ProcessEnv)).toMatchObject({
|
|
enabled: true,
|
|
configured: true,
|
|
authentication: "workload_oidc",
|
|
});
|
|
});
|
|
|
|
it("prefers workload OIDC over the access-token fallback", () => {
|
|
expect(vercelConnectSdkOptions({
|
|
VERCEL_OIDC_TOKEN: "workload-token",
|
|
PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN: "stale-fallback-token",
|
|
} as NodeJS.ProcessEnv)).toEqual({});
|
|
expect(vercelConnectSdkOptions({
|
|
PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN: "fallback-token",
|
|
} as NodeJS.ProcessEnv, true)).toEqual({
|
|
vercelToken: "fallback-token",
|
|
forceRefresh: true,
|
|
});
|
|
});
|
|
|
|
it("derives stable company- and user-bound subjects without browser input", () => {
|
|
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "instance-one");
|
|
const base = {
|
|
credential,
|
|
connectionId: "connection-one",
|
|
companyId: "company-one",
|
|
grantKind: "user" as const,
|
|
subjectUserId: "user-one",
|
|
};
|
|
const first = deriveVercelConnectSubject(base);
|
|
expect(first).toEqual(deriveVercelConnectSubject(base));
|
|
expect(first.subject).toMatchObject({ type: "user", id: expect.stringMatching(/^pc_/) });
|
|
expect(deriveVercelConnectSubject({ ...base, companyId: "company-two" }).subject).not.toEqual(first.subject);
|
|
expect(deriveVercelConnectSubject({ ...base, subjectUserId: "user-two" }).subject).not.toEqual(first.subject);
|
|
});
|
|
|
|
it("uses app subjects without reusable browser-provided identifiers", () => {
|
|
const appCredential = { ...credential, principalMode: "app" as const };
|
|
expect(deriveVercelConnectSubject({
|
|
credential: appCredential,
|
|
connectionId: "connection-one",
|
|
companyId: "company-one",
|
|
grantKind: "organization",
|
|
})).toEqual({ subject: { type: "app" } });
|
|
expect(vercelTokenRequest({
|
|
credential: appCredential,
|
|
connectionId: "connection-one",
|
|
companyId: "company-one",
|
|
resources: ["https://mcp.posthog.com/mcp"],
|
|
grant: { kind: "organization", subjectUserId: null, externalCredential: null },
|
|
})).toMatchObject({
|
|
connector: "posthog-paperclip",
|
|
subject: { type: "app" },
|
|
resources: ["https://mcp.posthog.com/mcp"],
|
|
});
|
|
});
|
|
|
|
it("persists only the allow-listed grant summary, never bearer or upstream claims", () => {
|
|
const reference = vercelGrantReference({
|
|
credential,
|
|
subjectId: "pc_subject",
|
|
verifiedAt: new Date("2026-08-26T12:00:00.000Z"),
|
|
token: {
|
|
token: "provider-bearer-must-not-persist",
|
|
tokenId: "stk_123",
|
|
expiresAt: Date.parse("2026-08-26T13:00:00.000Z"),
|
|
connector: { id: "scl_posthog", uid: "posthog-paperclip", type: "api-key" },
|
|
installationId: "installation-1",
|
|
tenantId: "project-1",
|
|
claims: { email: "private@example.com" },
|
|
metadata: { arbitrary: "private" },
|
|
},
|
|
});
|
|
expect(reference).toEqual({
|
|
provider: "vercel_connect",
|
|
subjectType: "user",
|
|
subjectId: "pc_subject",
|
|
installationId: "installation-1",
|
|
tenantId: "project-1",
|
|
tokenId: "stk_123",
|
|
expiresAt: "2026-08-26T13:00:00.000Z",
|
|
lastVerifiedAt: "2026-08-26T12:00:00.000Z",
|
|
});
|
|
expect(JSON.stringify(reference)).not.toContain("provider-bearer");
|
|
expect(JSON.stringify(reference)).not.toContain("private@example.com");
|
|
});
|
|
});
|