Files
PaperClipAI/server/src/services/vercel-connect.test.ts
T
Dotta d387cc0ff0 feat(connections): add managed external MCP connectors (#12346)
## 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
2026-08-29 12:08:34 -05:00

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");
});
});