diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 0241771ebf5..36b4cdc2b7e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2034,6 +2034,14 @@ pnpm secrets:migrate-inline-env --apply # apply migration ## Internal Connection Token Brokers +Paperclip Cloud connector identities support `development`, `test`, `staging`, +and `production`. To enroll a self-hosted instance against the test broker, set +`PAPERCLIP_CLOUD_CONNECTOR_BASE_URL=https://test.paperclip.app`; its environment +is inferred as `test`. Managed instances also receive +`PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT=test`. Standard Cloud broker hosts reject +identities for another environment, and enrollment keys remain local to the +selected broker. + Connection token exchanges carry a resolved parent credential, so their URLs are public-only by default even in local/private deployments. To intentionally use an internal broker, configure an exact hostname allowlist: diff --git a/server/src/services/paperclip-cloud-connector-enrollment.test.ts b/server/src/services/paperclip-cloud-connector-enrollment.test.ts index 6443eafef32..8d731f426ba 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.test.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.test.ts @@ -193,6 +193,35 @@ describe("Paperclip Cloud self-host enrollment", () => { expect(paperclipCloudConnectorEnrollmentStatus({ PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", })).toMatchObject({ environment: "staging" }); + expect(paperclipCloudConnectorEnrollmentStatus({ + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app", + })).toMatchObject({ environment: "test" }); + }); + + it.each(["development", "staging", "production"])("rejects %s identity at the test broker", (environment) => { + expect(() => paperclipCloudConnectorEnrollmentStatus({ + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: environment, + })).toThrow(/broker and environment do not match/); + }); + + it("persists test enrollment identity and signs requests for the test broker", async () => { + const origin = "https://tenant.test.paperclip.app"; + const request = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + if (String(input).endsWith("/enrollments")) { + expect(body).toMatchObject({ environment: "test", origin }); + return Response.json({ enrollmentId: "enroll-test", verificationUrl: "https://test.paperclip.app/connections/enroll?id=enroll-test", expiresAt: new Date(Date.now() + 60_000).toISOString() }, { status: 201 }); + } + const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8")); + expect(claims).toMatchObject({ env: "test", aud: "https://test.paperclip.app/v1/connector/enrollment-claims" }); + return Response.json({ id: loadPaperclipCloudConnectorIdentity()!.instanceId, environment: "test", origins: [origin] }); + }); + const env = { PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app" }; + await startPaperclipCloudConnectorEnrollment({ origin, env, request: request as typeof fetch }); + const pending = loadPaperclipCloudConnectorIdentity()!; + await completePaperclipCloudConnectorEnrollment({ enrollmentId: "enroll-test", approvalCode: "approval-code", state: pending.pending!.returnState, env, request: request as typeof fetch }); + expect(paperclipCloudConnectorConfigFromEnv(env)).toMatchObject({ environment: "test", baseUrl: "https://test.paperclip.app", instanceId: pending.instanceId }); }); it("rotates a non-active identity instead of mixing enrollment targets", async () => { diff --git a/server/src/services/paperclip-cloud-connector-enrollment.ts b/server/src/services/paperclip-cloud-connector-enrollment.ts index ba5de762ba0..0fbbd9755c7 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.ts @@ -17,7 +17,7 @@ const IDENTITY_VERSION = 1; const ENROLLMENT_FILE = "paperclip-cloud-connector.json"; const REQUEST_TYPE = "paperclip-cloud-connector-request+jwt"; -export type LocalConnectorEnvironment = "development" | "staging" | "production"; +export type LocalConnectorEnvironment = "development" | "test" | "staging" | "production"; type PendingEnrollment = { enrollmentId: string; @@ -350,10 +350,13 @@ function connectorEnvironment( ? "production" : host === "my-staging.paperclip.app" ? "staging" - : "development"; + : host === "test.paperclip.app" + ? "test" + : "development"; const value = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim() || fallback || inferred; if (!isEnvironment(value)) throw new Error("Paperclip Cloud connector environment is invalid"); if ((host === "my.paperclip.app" && value !== "production") + || (host === "test.paperclip.app" && value !== "test") || (host === "my-staging.paperclip.app" && value !== "staging")) { throw new Error("Paperclip Cloud connector broker and environment do not match"); } @@ -390,7 +393,7 @@ function hasManagedConnectorIdentityOverride(env: NodeJS.ProcessEnv): boolean { } function isEnvironment(value: unknown): value is LocalConnectorEnvironment { - return value === "development" || value === "staging" || value === "production"; + return value === "development" || value === "test" || value === "staging" || value === "production"; } function normalizeBrokerOrigin(value: string): string { diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index a2a0b37abe0..999236c6c54 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -55,6 +55,24 @@ function config() { } describe("Paperclip Cloud connector", () => { + it("accepts a managed test identity and rejects other environments at its broker", () => { + const identity = config().config; + const env = { + PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: identity.instanceId, + PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: identity.signPrivateKey, + PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: identity.sealPrivateKey, + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "test", + }; + expect(paperclipCloudConnectorConfigFromEnv(env)).toMatchObject({ environment: "test", baseUrl: env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL }); + for (const environment of ["development", "staging", "production"]) { + expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: environment })).toThrow(/broker and environment do not match/); + } + for (const baseUrl of ["https://my.paperclip.app", "https://my-staging.paperclip.app"]) { + expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: baseUrl })).toThrow(/broker and environment do not match/); + } + }); + async function rejection(response: Response) { const connector = createPaperclipCloudConnector({ config: config().config, diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 241ff937b57..3f330292dab 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -32,7 +32,7 @@ export const GMAIL_CONNECTOR_SCOPES = [ ] as const; export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; -export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; +export type PaperclipCloudConnectorEnvironment = "development" | "test" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app"; export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot"; export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack"; @@ -232,7 +232,7 @@ export function paperclipCloudConnectorConfigFromEnv( if (values.some((value) => !value)) { throw new PaperclipCloudConnectorError("Paperclip Cloud connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE"); } - if (environment !== "development" && environment !== "staging" && environment !== "production") { + if (environment !== "development" && environment !== "test" && environment !== "staging" && environment !== "production") { throw new PaperclipCloudConnectorError("Paperclip Cloud connector environment is invalid", "CONNECTOR_CONFIG_INVALID"); } const parsedBaseUrl = new URL(baseUrl); @@ -244,6 +244,7 @@ export function paperclipCloudConnectorConfigFromEnv( } const brokerHost = parsedBaseUrl.hostname.toLowerCase(); if ((brokerHost === "my.paperclip.app" && environment !== "production") + || (brokerHost === "test.paperclip.app" && environment !== "test") || (brokerHost === "my-staging.paperclip.app" && environment !== "staging")) { throw new PaperclipCloudConnectorError( "Paperclip Cloud connector broker and environment do not match", diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 43cb010466d..1702478e54d 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -106,7 +106,7 @@ export type CloudConnectorEnrollmentStatus = { status: "not_configured" | "unenrolled" | "pending" | "active" | "suspended" | "unverified"; brokerBaseUrl: string; instanceId: string | null; - environment: "development" | "staging" | "production"; + environment: "development" | "test" | "staging" | "production"; origins: string[]; verificationUrl?: string; expiresAt?: string;